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