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