xref: /aosp_15_r20/tools/asuite/atest/coverage/coverage.py (revision c2e18aaa1096c836b086f94603d04f4eb9cf37f5)
1# Copyright 2022, 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"""Code coverage instrumentation and collection functionality."""
15
16import logging
17import os
18from pathlib import Path
19import subprocess
20from typing import List, Set
21
22from atest import atest_utils
23from atest import constants
24from atest import module_info
25from atest.test_finders import test_info
26
27
28def build_env_vars():
29  """Environment variables for building with code coverage instrumentation.
30
31  Returns:
32      A dict with the environment variables to set.
33  """
34  env_vars = {
35      'CLANG_COVERAGE': 'true',
36      'NATIVE_COVERAGE_PATHS': '*',
37      'EMMA_INSTRUMENT': 'true',
38      'EMMA_INSTRUMENT_FRAMEWORK': 'true',
39      'LLVM_PROFILE_FILE': '/dev/null',
40  }
41  return env_vars
42
43
44def tf_args(mod_info):
45  """TradeFed command line arguments needed to collect code coverage.
46
47  Returns:
48      A list of the command line arguments to append.
49  """
50  build_top = Path(os.environ.get(constants.ANDROID_BUILD_TOP))
51  clang_version = _get_clang_version(build_top)
52  llvm_profdata = build_top.joinpath(
53      f'prebuilts/clang/host/linux-x86/{clang_version}'
54  )
55  jacocoagent_paths = mod_info.get_installed_paths('jacocoagent')
56  return (
57      '--coverage',
58      '--coverage-toolchain',
59      'JACOCO',
60      '--coverage-toolchain',
61      'CLANG',
62      '--auto-collect',
63      'JAVA_COVERAGE',
64      '--auto-collect',
65      'CLANG_COVERAGE',
66      '--llvm-profdata-path',
67      str(llvm_profdata),
68      '--jacocoagent-path',
69      str(jacocoagent_paths[0]),
70  )
71
72
73def _get_clang_version(build_top):
74  """Finds out current toolchain version."""
75  version_output = subprocess.check_output(
76      f'{build_top}/build/soong/scripts/get_clang_version.py', text=True
77  )
78  return version_output.strip()
79
80
81def build_modules():
82  """Build modules needed for coverage report generation."""
83  return ('jacoco_to_lcov_converter', 'jacocoagent')
84
85
86def generate_coverage_report(
87    results_dir: str,
88    test_infos: List[test_info.TestInfo],
89    mod_info: module_info.ModuleInfo,
90    is_host_enabled: bool,
91    code_under_test: Set[str],
92):
93  """Generates HTML code coverage reports based on the test info.
94
95  Args:
96    results_dir: The directory containing the test results
97    test_infos: The TestInfo objects for this invocation
98    mod_info: The ModuleInfo object containing all build module information
99    is_host_enabled: True if --host was specified
100    code_under_test: The set of modules to include in the coverage report
101  """
102  if not code_under_test:
103    # No code-under-test was specified on the command line. Deduce the values
104    # from module-info or from the test.
105    code_under_test = _deduce_code_under_test(test_infos, mod_info)
106
107  logging.debug(f'Code-under-test: {code_under_test}')
108
109  # Collect coverage metadata files from the build for coverage report generation.
110  jacoco_report_jars = _collect_java_report_jars(
111      code_under_test, mod_info, is_host_enabled
112  )
113  unstripped_native_binaries = _collect_native_report_binaries(
114      code_under_test, mod_info, is_host_enabled
115  )
116
117  if jacoco_report_jars:
118    _generate_java_coverage_report(
119        jacoco_report_jars,
120        _get_all_src_paths(code_under_test, mod_info),
121        results_dir,
122        mod_info,
123    )
124
125  if unstripped_native_binaries:
126    _generate_native_coverage_report(unstripped_native_binaries, results_dir)
127
128
129def _deduce_code_under_test(
130    test_infos: List[test_info.TestInfo],
131    mod_info: module_info.ModuleInfo,
132) -> Set[str]:
133  """Deduces the code-under-test from the test info and module info.
134
135  If the test info contains code-under-test information, that is used.
136  Otherwise, the dependencies of the test are used.
137
138  Args:
139    test_infos: The TestInfo objects for this invocation
140    mod_info: The ModuleInfo object containing all build module information
141
142  Returns:
143    The set of modules to include in the coverage report
144  """
145  code_under_test = set()
146
147  for test_info in test_infos:
148    code_under_test.update(
149        mod_info.get_code_under_test(test_info.raw_test_name)
150    )
151
152  if code_under_test:
153    return code_under_test
154
155  # No code-under-test was specified in ModuleInfo, default to using dependency
156  # information of the test.
157  for test_info in test_infos:
158    code_under_test.update(_get_test_deps(test_info, mod_info))
159
160  return code_under_test
161
162
163def _get_test_deps(test_info, mod_info):
164  """Gets all dependencies of the TestInfo, including Mainline modules."""
165  deps = set()
166
167  deps.add(test_info.raw_test_name)
168  deps |= _get_transitive_module_deps(
169      mod_info.get_module_info(test_info.raw_test_name), mod_info, deps
170  )
171
172  # Include dependencies of any Mainline modules specified as well.
173  for mainline_module in test_info.mainline_modules:
174    deps.add(mainline_module)
175    deps |= _get_transitive_module_deps(
176        mod_info.get_module_info(mainline_module), mod_info, deps
177    )
178
179  return deps
180
181
182def _get_transitive_module_deps(
183    info, mod_info: module_info.ModuleInfo, seen: Set[str]
184) -> Set[str]:
185  """Gets all dependencies of the module, including .impl versions."""
186  deps = set()
187
188  for dep in info.get(constants.MODULE_DEPENDENCIES, []):
189    if dep in seen:
190      continue
191
192    seen.add(dep)
193
194    dep_info = mod_info.get_module_info(dep)
195
196    # Mainline modules sometimes depend on `java_sdk_library` modules that
197    # generate synthetic build modules ending in `.impl` which do not appear
198    # in the ModuleInfo. Strip this suffix to prevent incomplete dependency
199    # information when generating coverage reports.
200    # TODO(olivernguyen): Reconcile this with
201    # ModuleInfo.get_module_dependency(...).
202    if not dep_info:
203      dep = dep.removesuffix('.impl')
204      dep_info = mod_info.get_module_info(dep)
205
206    if not dep_info:
207      continue
208
209    deps.add(dep)
210    deps |= _get_transitive_module_deps(dep_info, mod_info, seen)
211
212  return deps
213
214
215def _collect_java_report_jars(code_under_test, mod_info, is_host_enabled):
216  soong_intermediates = atest_utils.get_build_out_dir('soong/.intermediates')
217  report_jars = {}
218
219  for module in code_under_test:
220    for path in mod_info.get_paths(module):
221      if not path:
222        continue
223      module_dir = soong_intermediates.joinpath(path, module)
224      # Check for uninstrumented Java class files to report coverage.
225      classfiles = list(module_dir.rglob('jacoco-report-classes/*.jar'))
226      if classfiles:
227        report_jars[module] = classfiles
228
229    # Host tests use the test itself to generate the coverage report.
230    info = mod_info.get_module_info(module)
231    if not info:
232      continue
233    if is_host_enabled or not mod_info.requires_device(info):
234      installed = mod_info.get_installed_paths(module)
235      installed_jars = [str(f) for f in installed if f.suffix == '.jar']
236      if installed_jars:
237        report_jars[module] = installed_jars
238
239  return report_jars
240
241
242def _collect_native_report_binaries(code_under_test, mod_info, is_host_enabled):
243  soong_intermediates = atest_utils.get_build_out_dir('soong/.intermediates')
244  report_binaries = set()
245
246  for module in code_under_test:
247    for path in mod_info.get_paths(module):
248      if not path:
249        continue
250      module_dir = soong_intermediates.joinpath(path, module)
251      # Check for unstripped binaries to report coverage.
252      report_binaries.update(module_dir.glob('*cov*/**/unstripped/*'))
253
254    # Host tests use the test itself to generate the coverage report.
255    info = mod_info.get_module_info(module)
256    if not info:
257      continue
258    if constants.MODULE_CLASS_NATIVE_TESTS not in info.get(
259        constants.MODULE_CLASS, []
260    ):
261      continue
262    if is_host_enabled or not mod_info.requires_device(info):
263      report_binaries.update(
264          str(f) for f in mod_info.get_installed_paths(module)
265      )
266
267  return _strip_irrelevant_objects(report_binaries)
268
269
270def _strip_irrelevant_objects(files):
271  objects = set()
272  for file in files:
273    cmd = ['llvm-readobj', file]
274    try:
275      subprocess.run(
276          cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
277      )
278      objects.add(file)
279    except subprocess.CalledProcessError:
280      logging.debug(f'{file} is not a valid object file, skipping.')
281  return objects
282
283
284def _get_all_src_paths(modules, mod_info):
285  """Gets the set of directories containing any source files from the modules."""
286  src_paths = set()
287
288  for module in modules:
289    info = mod_info.get_module_info(module)
290    if not info:
291      continue
292
293    # Do not report coverage for test modules.
294    if mod_info.is_testable_module(info):
295      continue
296
297    src_paths.update(
298        os.path.dirname(f) for f in info.get(constants.MODULE_SRCS, [])
299    )
300
301  src_paths = {p for p in src_paths if not _is_generated_code(p)}
302  return src_paths
303
304
305def _is_generated_code(path):
306  return 'soong/.intermediates' in path
307
308
309def _generate_java_coverage_report(
310    report_jars, src_paths, results_dir, mod_info
311):
312  build_top = os.environ.get(constants.ANDROID_BUILD_TOP)
313  out_dir = os.path.join(results_dir, 'java_coverage')
314  jacoco_files = atest_utils.find_files(results_dir, '*.ec')
315
316  os.mkdir(out_dir)
317  jacoco_lcov = mod_info.get_module_info('jacoco_to_lcov_converter')
318  jacoco_lcov = os.path.join(build_top, jacoco_lcov['installed'][0])
319  lcov_reports = []
320
321  for name, classfiles in report_jars.items():
322    dest = f'{out_dir}/{name}.info'
323    cmd = [jacoco_lcov, '-o', dest]
324    for classfile in classfiles:
325      cmd.append('-classfiles')
326      cmd.append(str(classfile))
327    for src_path in src_paths:
328      cmd.append('-sourcepath')
329      cmd.append(src_path)
330    cmd.extend(jacoco_files)
331    logging.debug(f'Running jacoco_lcov to generate coverage report: {cmd}.')
332    try:
333      subprocess.run(
334          cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
335      )
336    except subprocess.CalledProcessError as err:
337      atest_utils.colorful_print(
338          f'Failed to generate coverage for {name}:', constants.RED
339      )
340      logging.exception(err.stdout)
341    atest_utils.colorful_print(
342        f'Coverage for {name} written to {dest}.', constants.GREEN
343    )
344    lcov_reports.append(dest)
345
346  _generate_lcov_report(out_dir, lcov_reports, build_top)
347
348
349def _generate_native_coverage_report(unstripped_native_binaries, results_dir):
350  build_top = os.environ.get(constants.ANDROID_BUILD_TOP)
351  out_dir = os.path.join(results_dir, 'native_coverage')
352  profdata_files = atest_utils.find_files(results_dir, '*.profdata')
353
354  os.mkdir(out_dir)
355  cmd = [
356      'llvm-cov',
357      'show',
358      '-format=html',
359      f'-output-dir={out_dir}',
360      f'-path-equivalence=/proc/self/cwd,{build_top}',
361  ]
362  for profdata in profdata_files:
363    cmd.append('--instr-profile')
364    cmd.append(profdata)
365  for binary in unstripped_native_binaries:
366    cmd.append(f'--object={str(binary)}')
367
368  logging.debug(f'Running llvm-cov to generate coverage report: {cmd}.')
369  try:
370    subprocess.run(
371        cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
372    )
373    atest_utils.colorful_print(
374        f'Native coverage written to {out_dir}.', constants.GREEN
375    )
376  except subprocess.CalledProcessError as err:
377    atest_utils.colorful_print(
378        'Failed to generate native code coverage.', constants.RED
379    )
380    logging.exception(err.stdout)
381
382
383def _generate_lcov_report(out_dir, reports, root_dir=None):
384  cmd = [
385      'genhtml',
386      '-q',
387      '-o',
388      out_dir,
389      # TODO(b/361334044): These errors are ignored to continue to generate a
390      # flawed result but ultimately need to be resolved, see bug for details.
391      '--ignore-errors',
392      'unmapped,range,empty,corrupt',
393  ]
394  if root_dir:
395    cmd.extend(['-p', root_dir])
396  cmd.extend(reports)
397  logging.debug(f'Running genhtml to generate coverage report: {cmd}.')
398  try:
399    subprocess.run(
400        cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
401    )
402    atest_utils.colorful_print(
403        f'Code coverage report written to {out_dir}.', constants.GREEN
404    )
405    atest_utils.colorful_print(
406        f'To open, Ctrl+Click on file://{out_dir}/index.html', constants.GREEN
407    )
408  except subprocess.CalledProcessError as err:
409    atest_utils.colorful_print(
410        'Failed to generate HTML coverage report.', constants.RED
411    )
412    logging.exception(err.stdout)
413  except FileNotFoundError:
414    atest_utils.colorful_print('genhtml is not on the $PATH.', constants.RED)
415    atest_utils.colorful_print(
416        'Run `sudo apt-get install lcov -y` to install this tool.',
417        constants.RED,
418    )
419