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