Snap for 11959661 from 5370e3ad9f95f67009da8ccef5acd876769288e5 to 24Q3-release

Change-Id: Id067d3cc9da647c6685bcafc244c422c7a983566
diff --git a/atest/arg_parser.py b/atest/arg_parser.py
index 8884d3f..c698f58 100644
--- a/atest/arg_parser.py
+++ b/atest/arg_parser.py
@@ -146,6 +146,17 @@
       action='store_true',
       help='Disable test teardown and cleanup.',
   )
+
+  parser.add_argument(
+      '--code-under-test',
+      type=lambda value: set(value.split(',')),
+      help=(
+          'Comma-separated list of modules whose sources should be included in'
+          ' the code coverage report. The dependencies of these modules are not'
+          ' included. For use with the --experimental-coverage flag.'
+      ),
+  )
+
   parser.add_argument(
       '--experimental-coverage',
       action='store_true',
diff --git a/atest/atest_main.py b/atest/atest_main.py
index d0f994131..2eb5956 100755
--- a/atest/atest_main.py
+++ b/atest/atest_main.py
@@ -1168,6 +1168,7 @@
           test_infos,
           mod_info,
           extra_args.get(constants.HOST, False),
+          args.code_under_test,
       )
 
   metrics.RunTestsFinishEvent(
diff --git a/atest/coverage/coverage.py b/atest/coverage/coverage.py
index e4e89cf..2aa4e7e 100644
--- a/atest/coverage/coverage.py
+++ b/atest/coverage/coverage.py
@@ -87,72 +87,93 @@
     test_infos: List[test_info.TestInfo],
     mod_info: module_info.ModuleInfo,
     is_host_enabled: bool,
+    code_under_test: Set[str],
 ):
-  """Generates HTML code coverage reports based on the test info."""
+  """Generates HTML code coverage reports based on the test info.
 
-  soong_intermediates = atest_utils.get_build_out_dir('soong/.intermediates')
+  Args:
+    results_dir: The directory containing the test results
+    test_infos: The TestInfo objects for this invocation
+    mod_info: The ModuleInfo object containing all build module information
+    is_host_enabled: True if --host was specified
+    code_under_test: The set of modules to include in the coverage report
+  """
+  if not code_under_test:
+    # No code-under-test was specified on the command line. Deduce the values
+    # from module-info or from the test.
+    code_under_test = _deduce_code_under_test(test_infos, mod_info)
 
-  # Collect dependency and source file information for the tests and any
-  # Mainline modules.
-  dep_modules = _get_test_deps(test_infos, mod_info)
-  src_paths = _get_all_src_paths(dep_modules, mod_info)
+  logging.debug(f'Code-under-test: {code_under_test}')
 
-  # Collect JaCoCo class jars from the build for coverage report generation.
-  jacoco_report_jars = {}
-  unstripped_native_binaries = set()
-  for module in dep_modules:
-    for path in mod_info.get_paths(module):
-      module_dir = soong_intermediates.joinpath(path, module)
-      # Check for uninstrumented Java class files to report coverage.
-      classfiles = list(module_dir.rglob('jacoco-report-classes/*.jar'))
-      if classfiles:
-        jacoco_report_jars[module] = classfiles
-
-      # Check for unstripped native binaries to report coverage.
-      unstripped_native_binaries.update(_find_native_binaries(module_dir))
-
-  # For host tests, use the test itself in the report generation.
-  for test_info in test_infos:
-    info = mod_info.get_module_info(test_info.raw_test_name)
-    if not info or (not is_host_enabled and mod_info.requires_device(info)):
-      continue
-
-    installed = mod_info.get_installed_paths(test_info.raw_test_name)
-    jars = [f for f in installed if f.suffix == '.jar']
-    if jars:
-      jacoco_report_jars[test_info.raw_test_name] = jars
-    elif constants.MODULE_CLASS_NATIVE_TESTS in test_info.module_class:
-      unstripped_native_binaries.update(installed)
+  # Collect coverage metadata files from the build for coverage report generation.
+  jacoco_report_jars = _collect_java_report_jars(
+      code_under_test, mod_info, is_host_enabled
+  )
+  unstripped_native_binaries = _collect_native_report_binaries(
+      code_under_test, mod_info, is_host_enabled
+  )
 
   if jacoco_report_jars:
     _generate_java_coverage_report(
-        jacoco_report_jars, src_paths, results_dir, mod_info
+        jacoco_report_jars,
+        _get_all_src_paths(code_under_test, mod_info),
+        results_dir,
+        mod_info,
     )
 
   if unstripped_native_binaries:
     _generate_native_coverage_report(unstripped_native_binaries, results_dir)
 
 
-def _get_test_deps(test_infos, mod_info):
+def _deduce_code_under_test(
+    test_infos: List[test_info.TestInfo],
+    mod_info: module_info.ModuleInfo,
+) -> Set[str]:
+  """Deduces the code-under-test from the test info and module info.
+  If the test info contains code-under-test information, that is used.
+  Otherwise, the dependencies of the test are used.
+
+  Args:
+    test_infos: The TestInfo objects for this invocation
+    mod_info: The ModuleInfo object containing all build module information
+
+  Returns:
+    The set of modules to include in the coverage report
+  """
+  code_under_test = set()
+
+  for test_info in test_infos:
+    code_under_test.update(
+        mod_info.get_code_under_test(test_info.raw_test_name)
+    )
+
+  if code_under_test:
+    return code_under_test
+
+  # No code-under-test was specified in ModuleInfo, default to using dependency
+  # information of the test.
+  for test_info in test_infos:
+    code_under_test.update(_get_test_deps(test_info, mod_info))
+
+  return code_under_test
+
+
+def _get_test_deps(test_info, mod_info):
   """Gets all dependencies of the TestInfo, including Mainline modules."""
   deps = set()
 
-  for info in test_infos:
-    deps.add(info.raw_test_name)
+  deps.add(test_info.raw_test_name)
+  deps |= _get_transitive_module_deps(
+      mod_info.get_module_info(test_info.raw_test_name), mod_info, deps
+  )
+
+  # Include dependencies of any Mainline modules specified as well.
+  for mainline_module in test_info.mainline_modules:
+    deps.add(mainline_module)
     deps |= _get_transitive_module_deps(
-        mod_info.get_module_info(info.raw_test_name), mod_info, deps
+        mod_info.get_module_info(mainline_module), mod_info, deps
     )
 
-    # Include dependencies of any Mainline modules specified as well.
-    if not info.mainline_modules:
-      continue
-
-    for mainline_module in info.mainline_modules:
-      deps.add(mainline_module)
-      deps |= _get_transitive_module_deps(
-          mod_info.get_module_info(mainline_module), mod_info, deps
-      )
-
   return deps
 
 
@@ -189,6 +210,61 @@
   return deps
 
 
+def _collect_java_report_jars(code_under_test, mod_info, is_host_enabled):
+  soong_intermediates = atest_utils.get_build_out_dir('soong/.intermediates')
+  report_jars = {}
+
+  for module in code_under_test:
+    for path in mod_info.get_paths(module):
+      if not path:
+        continue
+      module_dir = soong_intermediates.joinpath(path, module)
+      # Check for uninstrumented Java class files to report coverage.
+      classfiles = list(module_dir.rglob('jacoco-report-classes/*.jar'))
+      if classfiles:
+        report_jars[module] = classfiles
+
+    # Host tests use the test itself to generate the coverage report.
+    info = mod_info.get_module_info(module)
+    if not info:
+      continue
+    if is_host_enabled or not mod_info.requires_device(info):
+      installed = mod_info.get_installed_paths(module)
+      installed_jars = [str(f) for f in installed if f.suffix == '.jar']
+      if installed_jars:
+        report_jars[module] = installed_jars
+
+  return report_jars
+
+
+def _collect_native_report_binaries(code_under_test, mod_info, is_host_enabled):
+  soong_intermediates = atest_utils.get_build_out_dir('soong/.intermediates')
+  report_binaries = set()
+
+  for module in code_under_test:
+    for path in mod_info.get_paths(module):
+      if not path:
+        continue
+      module_dir = soong_intermediates.joinpath(path, module)
+      # Check for unstripped binaries to report coverage.
+      report_binaries.update(_find_native_binaries(module_dir))
+
+    # Host tests use the test itself to generate the coverage report.
+    info = mod_info.get_module_info(module)
+    if not info:
+      continue
+    if constants.MODULE_CLASS_NATIVE_TESTS not in info.get(
+        constants.MODULE_CLASS, []
+    ):
+      continue
+    if is_host_enabled or not mod_info.requires_device(info):
+      report_binaries.update(
+          str(f) for f in mod_info.get_installed_paths(module)
+      )
+
+  return report_binaries
+
+
 def _find_native_binaries(module_dir):
   files = module_dir.glob('*cov*/**/unstripped/*')
 
@@ -198,7 +274,7 @@
   # Exclude .d and .d.raw files. These are Rust dependency files and are also
   # stored in the unstripped directory.
   return [
-      file
+      str(file)
       for file in files
       if '.rsp' not in file.suffixes and '.d' not in file.suffixes
   ]
diff --git a/atest/coverage/coverage_unittest.py b/atest/coverage/coverage_unittest.py
new file mode 100755
index 0000000..f68b368
--- /dev/null
+++ b/atest/coverage/coverage_unittest.py
@@ -0,0 +1,335 @@
+#!/usr/bin/env python3
+#
+# Copyright 2024, The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Unit tests for coverage."""
+
+# pylint: disable=invalid-name
+
+from pathlib import PosixPath
+import unittest
+from unittest import mock
+from atest import atest_utils
+from atest import constants
+from atest import module_info
+from atest.coverage import coverage
+from atest.test_finders import test_info
+
+
+class DeduceCodeUnderTestUnittests(unittest.TestCase):
+  """Tests for _deduce_code_under_test."""
+
+  def test_code_under_test_is_defined_return_modules_in_code_under_test(self):
+    mod_info = create_module_info([
+        module(
+            name='test1',
+            dependencies=['dep1', 'dep2'],
+            code_under_test=['dep1'],
+        ),
+        module(name='dep1', dependencies=['dep1dep1', 'dep1dep2']),
+        module(name='dep1dep1'),
+        module(name='dep1dep2', dependencies=['dep1dep2dep']),
+        module(name='dep1dep2dep'),
+        module(name='dep2'),
+    ])
+
+    self.assertEqual(
+        coverage._deduce_code_under_test([create_test_info('test1')], mod_info),
+        {'dep1'},
+    )
+
+  def test_code_under_test_not_defined_return_all_modules_from_one_test(self):
+    mod_info = create_module_info([
+        module(name='test1', dependencies=['dep1', 'dep2']),
+        module(name='dep1', dependencies=['dep1dep1', 'dep1dep2']),
+        module(name='dep1dep1'),
+        module(name='dep1dep2', dependencies=['dep1dep2dep']),
+        module(name='dep1dep2dep'),
+        module(name='dep2'),
+        module(name='shouldnotappear'),
+    ])
+
+    self.assertEqual(
+        coverage._deduce_code_under_test([create_test_info('test1')], mod_info),
+        {
+            'test1',
+            'dep1',
+            'dep2',
+            'dep1dep1',
+            'dep1dep2',
+            'dep1dep2dep',
+        },
+    )
+
+  def test_code_under_test_not_defined_return_all_modules_from_all_tests(self):
+    mod_info = create_module_info([
+        module(name='test1', dependencies=['testlib', 'test1dep']),
+        module(name='test2', dependencies=['testlib', 'test2dep']),
+        module(name='testlib', dependencies=['testlibdep']),
+        module(name='testlibdep'),
+        module(name='test1dep'),
+        module(name='test2dep'),
+        module(name='shouldnotappear'),
+    ])
+
+    self.assertEqual(
+        coverage._deduce_code_under_test(
+            [create_test_info('test1'), create_test_info('test2')], mod_info
+        ),
+        {'test1', 'test2', 'testlib', 'testlibdep', 'test1dep', 'test2dep'},
+    )
+
+
+class CollectJavaReportJarsUnittests(unittest.TestCase):
+  """Test cases for _collect_java_report_jars."""
+
+  @mock.patch.object(
+      atest_utils,
+      'get_build_out_dir',
+      return_value=PosixPath('/out/soong/.intermediates'),
+  )
+  @mock.patch.object(
+      PosixPath,
+      'rglob',
+      return_value=[
+          '/out/soong/.intermediates/path/to/java_lib/variant-name/jacoco-report-classes/java_lib.jar'
+      ],
+  )
+  def test_java_lib(self, _rglob, _get_build_out_dir):
+    code_under_test = {'java_lib'}
+    mod_info = create_module_info([
+        module(name='java_lib', path='path/to'),
+    ])
+
+    self.assertEqual(
+        coverage._collect_java_report_jars(code_under_test, mod_info, False),
+        {
+            'java_lib': [
+                '/out/soong/.intermediates/path/to/java_lib/variant-name/jacoco-report-classes/java_lib.jar'
+            ]
+        },
+    )
+
+  def test_host_test_includes_installed(self):
+    code_under_test = {'java_host_test'}
+    mod_info = create_module_info([
+        module(
+            name='java_host_test',
+            installed=[
+                '/path/to/out/host/java_host_test.jar',
+                '/path/to/out/host/java_host_test.config',
+            ],
+        ),
+    ])
+
+    self.assertEqual(
+        coverage._collect_java_report_jars(code_under_test, mod_info, True),
+        {'java_host_test': ['/path/to/out/host/java_host_test.jar']},
+    )
+
+
+class CollectNativeReportBinariesUnittests(unittest.TestCase):
+  """Test cases for _collect_native_report_binaries."""
+
+  @mock.patch.object(
+      atest_utils,
+      'get_build_out_dir',
+      return_value=PosixPath('/out/soong/.intermediates'),
+  )
+  @mock.patch.object(PosixPath, 'glob')
+  def test_native_binary(self, _glob, _get_build_out_dir):
+    _glob.return_value = [
+        PosixPath(
+            '/out/soong/.intermediates/path/to/native_bin/variant-name-cov/unstripped/native_bin'
+        )
+    ]
+    code_under_test = {'native_bin'}
+    mod_info = create_module_info([
+        module(name='native_bin', path='path/to'),
+    ])
+
+    self.assertEqual(
+        coverage._collect_native_report_binaries(
+            code_under_test, mod_info, False
+        ),
+        {
+            '/out/soong/.intermediates/path/to/native_bin/variant-name-cov/unstripped/native_bin'
+        },
+    )
+
+  @mock.patch.object(
+      atest_utils,
+      'get_build_out_dir',
+      return_value=PosixPath('/out/soong/.intermediates'),
+  )
+  @mock.patch.object(PosixPath, 'glob')
+  def test_skip_rsp_and_d_files(self, _glob, _get_build_out_dir):
+    _glob.return_value = [
+        PosixPath(
+            '/out/soong/.intermediates/path/to/native_bin/variant-name-cov/unstripped/native_bin'
+        ),
+        PosixPath(
+            '/out/soong/.intermediates/path/to/native_bin/variant-name-cov/unstripped/native_bin.rsp'
+        ),
+        PosixPath(
+            '/out/soong/.intermediates/path/to/native_bin/variant-name-cov/unstripped/native_bin.d'
+        ),
+    ]
+    code_under_test = {'native_bin'}
+    mod_info = create_module_info([
+        module(name='native_bin', path='path/to'),
+    ])
+
+    self.assertEqual(
+        coverage._collect_native_report_binaries(
+            code_under_test, mod_info, False
+        ),
+        {
+            '/out/soong/.intermediates/path/to/native_bin/variant-name-cov/unstripped/native_bin'
+        },
+    )
+
+  def test_host_test_includes_installed(self):
+    code_under_test = {'native_host_test'}
+    mod_info = create_module_info([
+        module(
+            name='native_host_test',
+            installed=['/out/host/nativetests/native_host_test'],
+            classes=[constants.MODULE_CLASS_NATIVE_TESTS],
+        ),
+    ])
+
+    self.assertEqual(
+        coverage._collect_native_report_binaries(
+            code_under_test, mod_info, True
+        ),
+        {'/out/host/nativetests/native_host_test'},
+    )
+
+
+class GenerateCoverageReportUnittests(unittest.TestCase):
+  """Tests for the code-under-test feature."""
+
+  @mock.patch.object(coverage, '_collect_java_report_jars', return_value={})
+  @mock.patch.object(
+      coverage, '_collect_native_report_binaries', return_value=set()
+  )
+  def test_generate_report_for_code_under_test_passed_in_from_atest(
+      self, _collect_native, _collect_java
+  ):
+    test_infos = [create_test_info('test')]
+    mod_info = create_module_info([
+        module(name='test', dependencies=['lib1', 'lib2']),
+        module(name='lib1'),
+        module(name='lib2', dependencies=['lib2dep']),
+        module(name='lib2dep'),
+    ])
+    code_under_test = ['lib1', 'lib2']
+
+    coverage.generate_coverage_report(
+        '/tmp/results_dir', test_infos, mod_info, True, code_under_test
+    )
+
+    _collect_java.assert_called_with(code_under_test, mod_info, True)
+    _collect_native.assert_called_with(code_under_test, mod_info, True)
+
+  @mock.patch.object(coverage, '_collect_java_report_jars', return_value={})
+  @mock.patch.object(
+      coverage, '_collect_native_report_binaries', return_value=set()
+  )
+  def test_generate_report_for_modules_get_from_deduce_code_under_test(
+      self, _collect_native, _collect_java
+  ):
+    test_infos = [create_test_info('test')]
+    mod_info = create_module_info([
+        module(name='test', dependencies=['lib1', 'lib2']),
+        module(name='lib1'),
+        module(name='lib2', dependencies=['lib2dep']),
+        module(name='lib2dep'),
+        module(name='not_a_dep'),
+    ])
+
+    coverage.generate_coverage_report(
+        '/tmp/results_dir', test_infos, mod_info, False, []
+    )
+
+    expected_code_under_test = {'test', 'lib1', 'lib2', 'lib2dep'}
+    _collect_java.assert_called_with(expected_code_under_test, mod_info, False)
+    _collect_native.assert_called_with(
+        expected_code_under_test, mod_info, False
+    )
+
+
+def create_module_info(modules=None):
+  """Wrapper function for creating module_info.ModuleInfo."""
+  name_to_module_info = {}
+  modules = modules or []
+
+  for m in modules:
+    name_to_module_info[m['module_name']] = m
+
+  return module_info.load_from_dict(name_to_module_info)
+
+
+# pylint: disable=too-many-arguments
+def module(
+    name=None,
+    path=None,
+    installed=None,
+    classes=None,
+    auto_test_config=None,
+    test_config=None,
+    shared_libs=None,
+    dependencies=None,
+    runtime_dependencies=None,
+    data=None,
+    data_dependencies=None,
+    compatibility_suites=None,
+    host_dependencies=None,
+    srcs=None,
+    supported_variants=None,
+    code_under_test=None,
+):
+  name = name or 'libhello'
+
+  m = {}
+
+  m['module_name'] = name
+  m['class'] = classes or []
+  m['path'] = [path or '']
+  m['installed'] = installed or []
+  m['is_unit_test'] = 'false'
+  m['auto_test_config'] = auto_test_config or []
+  m['test_config'] = test_config or []
+  m['shared_libs'] = shared_libs or []
+  m['runtime_dependencies'] = runtime_dependencies or []
+  m['dependencies'] = dependencies or []
+  m['data'] = data or []
+  m['data_dependencies'] = data_dependencies or []
+  m['compatibility_suites'] = compatibility_suites or []
+  m['host_dependencies'] = host_dependencies or []
+  m['srcs'] = srcs or []
+  m['supported_variants'] = supported_variants or []
+  m['code_under_test'] = code_under_test or []
+  return m
+
+
+def create_test_info(name='HelloWorldTest'):
+  """Helper function for creating test_info.TestInfo."""
+  return test_info.TestInfo(name, 'AtestTradefedRunner', set())
+
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/atest/module_info.py b/atest/module_info.py
index 2587dc2..d440e42 100644
--- a/atest/module_info.py
+++ b/atest/module_info.py
@@ -1166,6 +1166,19 @@
 
     return [_to_abs_path(p) for p in mod_info.get('installed', [])]
 
+  def get_code_under_test(self, module_name: str) -> List[str]:
+    """Return code under test from module info."""
+    mod_info = self.get_module_info(module_name)
+    if not mod_info:
+      atest_utils.colorful_print(
+          '\nmodule %s cannot be found in module info, skip generating'
+          ' coverage for it.' % module_name,
+          constants.YELLOW,
+      )
+      return []
+
+    return mod_info.get('code_under_test', [])
+
   def build_variants(self, info: Dict[str, Any]) -> List[str]:
     return info.get(constants.MODULE_SUPPORTED_VARIANTS, [])
 
diff --git a/atest/module_info_unittest.py b/atest/module_info_unittest.py
index 5dc7d17..9f87244 100755
--- a/atest/module_info_unittest.py
+++ b/atest/module_info_unittest.py
@@ -779,6 +779,40 @@
         [Path('/mocked/build_top/a/b/c/d')],
     )
 
+  def test_get_code_under_test_module_name_is_not_found_in_module_info(self):
+    mod_info = create_module_info([
+        module(
+            name='my_module',
+            code_under_test='code_under_test_module',
+        )
+    ])
+
+    # module_that_is_not_in_module_info is not found in mod_info.
+    self.assertEqual(
+        mod_info.get_code_under_test('module_that_is_not_in_module_info'), [],
+    )
+
+  def test_get_code_under_test_code_under_test_is_not_defined_in_module_info(self):
+    mod_info = create_module_info([module(name='my_module')])
+
+    # my_module is found in mod_info but code_under_test is not defined.
+    self.assertEqual(
+        mod_info.get_code_under_test('my_module'), [],
+    )
+
+  def test_get_code_under_test_code_under_test_is_defined_in_module_info(self):
+    mod_info = create_module_info([
+        module(
+            name='my_module',
+            code_under_test='code_under_test_module',
+        )
+    ])
+
+    self.assertEqual(
+        mod_info.get_code_under_test('my_module'),
+        'code_under_test_module',
+    )
+
 
 class ModuleInfoTestFixture(fake_filesystem_unittest.TestCase):
   """Fixture for ModuleInfo tests."""
@@ -1262,6 +1296,7 @@
     host_dependencies=None,
     srcs=None,
     supported_variants=None,
+    code_under_test=None,
 ):
   name = name or 'libhello'
 
@@ -1283,6 +1318,7 @@
   m['host_dependencies'] = host_dependencies or []
   m['srcs'] = srcs or []
   m['supported_variants'] = supported_variants or []
+  m['code_under_test'] = code_under_test or []
   return m