Home | History | Annotate | Download | only in coverage
      1 #!/usr/bin/env python
      2 # Copyright 2017 The PDFium Authors. All rights reserved.
      3 # Use of this source code is governed by a BSD-style license that can be
      4 # found in the LICENSE file.
      5 
      6 """Generates a coverage report for given binaries using llvm-gcov & lcov.
      7 
      8 Requires llvm-cov 3.5 or later.
      9 Requires lcov 1.11 or later.
     10 Requires that 'use_coverage = true' is set in args.gn.
     11 """
     12 
     13 import argparse
     14 from collections import namedtuple
     15 import os
     16 import pprint
     17 import re
     18 import subprocess
     19 import sys
     20 
     21 
     22 # Add src dir to path to avoid having to set PYTHONPATH.
     23 sys.path.append(
     24     os.path.abspath(
     25        os.path.join(
     26           os.path.dirname(__file__),
     27           os.path.pardir,
     28           os.path.pardir,
     29           os.path.pardir)))
     30 
     31 from testing.tools.common import GetBooleanGnArg
     32 
     33 
     34 # 'binary' is the file that is to be run for the test.
     35 # 'use_test_runner' indicates if 'binary' depends on test_runner.py and thus
     36 # requires special handling.
     37 TestSpec = namedtuple('TestSpec', 'binary, use_test_runner')
     38 
     39 # All of the coverage tests that the script knows how to run.
     40 COVERAGE_TESTS = {
     41     'pdfium_unittests': TestSpec('pdfium_unittests', False),
     42     'pdfium_embeddertests': TestSpec('pdfium_embeddertests', False),
     43     'corpus_tests': TestSpec('run_corpus_tests.py', True),
     44     'javascript_tests': TestSpec('run_javascript_tests.py', True),
     45     'pixel_tests': TestSpec('run_pixel_tests.py', True),
     46 }
     47 
     48 # Coverage tests that are known to take a long time to run, so are not in the
     49 # default set. The user must either explicitly invoke these tests or pass in
     50 # --slow.
     51 SLOW_TESTS = ['corpus_tests', 'javascript_tests', 'pixel_tests']
     52 
     53 class CoverageExecutor(object):
     54 
     55   def __init__(self, parser, args):
     56     """Initialize executor based on the current script environment
     57 
     58     Args:
     59         parser: argparse.ArgumentParser for handling improper inputs.
     60         args: Dictionary of arguments passed into the calling script.
     61     """
     62     self.dry_run = args['dry_run']
     63     self.verbose = args['verbose']
     64 
     65     llvm_cov = self.determine_proper_llvm_cov()
     66     if not llvm_cov:
     67       print 'Unable to find appropriate llvm-cov to use'
     68       sys.exit(1)
     69     self.lcov_env = os.environ
     70     self.lcov_env['LLVM_COV_BIN'] = llvm_cov
     71 
     72     self.lcov = self.determine_proper_lcov()
     73     if not self.lcov:
     74       print 'Unable to find appropriate lcov to use'
     75       sys.exit(1)
     76 
     77     self.coverage_files = set()
     78     self.source_directory = args['source_directory']
     79     if not os.path.isdir(self.source_directory):
     80       parser.error("'%s' needs to be a directory" % self.source_directory)
     81 
     82     self.build_directory = args['build_directory']
     83     if not os.path.isdir(self.build_directory):
     84       parser.error("'%s' needs to be a directory" % self.build_directory)
     85 
     86     self.coverage_tests = self.calculate_coverage_tests(args)
     87     if not self.coverage_tests:
     88       parser.error(
     89           'No valid tests in set to be run. This is likely due to bad command '
     90           'line arguments')
     91 
     92     if not GetBooleanGnArg('use_coverage', self.build_directory, self.verbose):
     93       parser.error(
     94           'use_coverage does not appear to be set to true for build, but is '
     95           'needed')
     96 
     97     self.use_goma = GetBooleanGnArg('use_goma', self.build_directory,
     98                                     self.verbose)
     99 
    100     self.output_directory = args['output_directory']
    101     if not os.path.exists(self.output_directory):
    102       if not self.dry_run:
    103         os.makedirs(self.output_directory)
    104     elif not os.path.isdir(self.output_directory):
    105       parser.error('%s exists, but is not a directory' % self.output_directory)
    106     self.coverage_totals_path = os.path.join(self.output_directory,
    107                                              'pdfium_totals.info')
    108 
    109   def check_output(self, args, dry_run=False, env=None):
    110     """Dry run aware wrapper of subprocess.check_output()"""
    111     if dry_run:
    112       print "Would have run '%s'" % ' '.join(args)
    113       return ''
    114 
    115     output = subprocess.check_output(args, env=env)
    116 
    117     if self.verbose:
    118       print "check_output(%s) returned '%s'" % (args, output)
    119     return output
    120 
    121   def call(self, args, dry_run=False, env=None):
    122     """Dry run aware wrapper of subprocess.call()"""
    123     if dry_run:
    124       print "Would have run '%s'" % ' '.join(args)
    125       return 0
    126 
    127     output = subprocess.call(args, env=env)
    128 
    129     if self.verbose:
    130       print 'call(%s) returned %s' % (args, output)
    131     return output
    132 
    133   def call_lcov(self, args, dry_run=False, needs_directory=True):
    134     """Wrapper to call lcov that adds appropriate arguments as needed."""
    135     lcov_args = [
    136         self.lcov, '--config-file',
    137         os.path.join(self.source_directory, 'testing', 'tools', 'coverage',
    138                      'lcovrc'),
    139         '--gcov-tool',
    140         os.path.join(self.source_directory, 'testing', 'tools', 'coverage',
    141                      'llvm-gcov')
    142     ]
    143     if needs_directory:
    144       lcov_args.extend(['--directory', self.source_directory])
    145     if not self.verbose:
    146       lcov_args.append('--quiet')
    147     lcov_args.extend(args)
    148     return self.call(lcov_args, dry_run=dry_run, env=self.lcov_env)
    149 
    150   def calculate_coverage_tests(self, args):
    151     """Determine which tests should be run."""
    152     testing_tools_directory = os.path.join(self.source_directory, 'testing',
    153                                            'tools')
    154     coverage_tests = {}
    155     for name in COVERAGE_TESTS.keys():
    156       test_spec = COVERAGE_TESTS[name]
    157       if test_spec.use_test_runner:
    158         binary_path = os.path.join(testing_tools_directory, test_spec.binary)
    159       else:
    160         binary_path = os.path.join(self.build_directory, test_spec.binary)
    161       coverage_tests[name] = TestSpec(binary_path, test_spec.use_test_runner)
    162 
    163     if args['tests']:
    164       return {name: spec
    165         for name, spec in coverage_tests.iteritems() if name in args['tests']}
    166     elif not args['slow']:
    167       return {name: spec
    168         for name, spec in coverage_tests.iteritems() if name not in SLOW_TESTS}
    169     else:
    170       return coverage_tests
    171 
    172   def find_acceptable_binary(self, binary_name, version_regex,
    173                              min_major_version, min_minor_version):
    174     """Find the newest version of binary that meets the min version."""
    175     min_version = (min_major_version, min_minor_version)
    176     parsed_versions = {}
    177     # When calling Bash builtins like this the command and arguments must be
    178     # passed in as a single string instead of as separate list members.
    179     potential_binaries = self.check_output(
    180         ['bash', '-c', 'compgen -abck %s' % binary_name]).splitlines()
    181     for binary in potential_binaries:
    182       if self.verbose:
    183         print 'Testing llvm-cov binary, %s' % binary
    184       # Assuming that scripts that don't respond to --version correctly are not
    185       # valid binaries and just happened to get globbed in. This is true for
    186       # lcov and llvm-cov
    187       try:
    188         version_output = self.check_output([binary, '--version']).splitlines()
    189       except subprocess.CalledProcessError:
    190         if self.verbose:
    191           print '--version returned failure status 1, so ignoring'
    192         continue
    193 
    194       for line in version_output:
    195         matcher = re.match(version_regex, line)
    196         if matcher:
    197           parsed_version = (int(matcher.group(1)), int(matcher.group(2)))
    198           if parsed_version >= min_version:
    199             parsed_versions[parsed_version] = binary
    200           break
    201 
    202     if not parsed_versions:
    203       return None
    204     return parsed_versions[max(parsed_versions)]
    205 
    206   def determine_proper_llvm_cov(self):
    207     """Find a version of llvm_cov that will work with the script."""
    208     version_regex = re.compile('.*LLVM version ([\d]+)\.([\d]+).*')
    209     return self.find_acceptable_binary('llvm-cov', version_regex, 3, 5)
    210 
    211   def determine_proper_lcov(self):
    212     """Find a version of lcov that will work with the script."""
    213     version_regex = re.compile('.*LCOV version ([\d]+)\.([\d]+).*')
    214     return self.find_acceptable_binary('lcov', version_regex, 1, 11)
    215 
    216   def build_binaries(self):
    217     """Build all the binaries that are going to be needed for coverage
    218     generation."""
    219     call_args = ['ninja']
    220     if self.use_goma:
    221       call_args.extend(['-j', '250'])
    222     call_args.extend(['-C', self.build_directory])
    223     return self.call(call_args, dry_run=self.dry_run) == 0
    224 
    225   def generate_coverage(self, name, spec):
    226     """Generate the coverage data for a test
    227 
    228     Args:
    229         name: Name associated with the test to be run. This is used as a label
    230               in the coverage data, so should be unique across all of the tests
    231               being run.
    232         spec: Tuple containing the path to the binary to run, and if this test
    233               uses test_runner.py.
    234     """
    235     if self.verbose:
    236       print "Generating coverage for test '%s', using data '%s'" % (name, spec)
    237     if not os.path.exists(spec.binary):
    238       print('Unable to generate coverage for %s, since it appears to not exist'
    239             ' @ %s') % (name, spec.binary)
    240       return False
    241 
    242     if self.call_lcov(['--zerocounters'], dry_run=self.dry_run):
    243       print 'Unable to clear counters for %s' % name
    244       return False
    245 
    246     binary_args = [spec.binary]
    247     if spec.use_test_runner:
    248       # Test runner performs multi-threading in the wrapper script, not the test
    249       # binary, so need -j 1, otherwise multiple processes will be writing to
    250       # the code coverage files, invalidating results.
    251       # TODO(pdfium:811): Rewrite how test runner tests work, so that they can
    252       # be run in multi-threaded mode.
    253       binary_args.extend(['-j', '1', '--build-dir', self.build_directory])
    254     if self.call(binary_args, dry_run=self.dry_run) and self.verbose:
    255       print('Running %s appears to have failed, which might affect '
    256             'results') % spec.binary
    257 
    258     output_raw_path = os.path.join(self.output_directory, '%s_raw.info' % name)
    259     if self.call_lcov(
    260         ['--capture', '--test-name', name, '--output-file', output_raw_path],
    261         dry_run=self.dry_run):
    262       print 'Unable to capture coverage data for %s' % name
    263       return False
    264 
    265     output_filtered_path = os.path.join(self.output_directory,
    266                                         '%s_filtered.info' % name)
    267     output_filters = [
    268         '/usr/include/*', '*third_party*', '*testing*', '*_unittest.cpp',
    269         '*_embeddertest.cpp'
    270     ]
    271     if self.call_lcov(
    272         ['--remove', output_raw_path] + output_filters +
    273         ['--output-file', output_filtered_path],
    274         dry_run=self.dry_run,
    275         needs_directory=False):
    276       print 'Unable to filter coverage data for %s' % name
    277       return False
    278 
    279     self.coverage_files.add(output_filtered_path)
    280     return True
    281 
    282   def merge_coverage(self):
    283     """Merge all of the coverage data sets into one for report generation."""
    284     merge_args = []
    285     for coverage_file in self.coverage_files:
    286       merge_args.extend(['--add-tracefile', coverage_file])
    287 
    288     merge_args.extend(['--output-file', self.coverage_totals_path])
    289     return self.call_lcov(
    290         merge_args, dry_run=self.dry_run, needs_directory=False) == 0
    291 
    292   def generate_report(self):
    293     """Produce HTML coverage report based on combined coverage data set."""
    294     config_file = os.path.join(
    295         self.source_directory, 'testing', 'tools', 'coverage', 'lcovrc')
    296 
    297     lcov_args = ['genhtml',
    298       '--config-file', config_file,
    299       '--legend',
    300       '--demangle-cpp',
    301       '--show-details',
    302       '--prefix', self.source_directory,
    303       '--ignore-errors',
    304       'source', self.coverage_totals_path,
    305       '--output-directory', self.output_directory]
    306     return self.call(lcov_args, dry_run=self.dry_run) == 0
    307 
    308   def run(self):
    309     """Setup environment, execute the tests and generate coverage report"""
    310     if not self.build_binaries():
    311       print 'Failed to successfully build binaries'
    312       return False
    313 
    314     for name in self.coverage_tests.keys():
    315       if not self.generate_coverage(name, self.coverage_tests[name]):
    316         print 'Failed to successfully generate coverage data'
    317         return False
    318 
    319     if not self.merge_coverage():
    320       print 'Failed to successfully merge generated coverage data'
    321       return False
    322 
    323     if not self.generate_report():
    324       print 'Failed to successfully generated coverage report'
    325       return False
    326 
    327     return True
    328 
    329 
    330 def main():
    331   parser = argparse.ArgumentParser()
    332   parser.formatter_class = argparse.RawDescriptionHelpFormatter
    333   parser.description = ('Generates a coverage report for given binaries using '
    334                         'llvm-cov & lcov.\n\n'
    335                         'Requires llvm-cov 3.5 or later.\n'
    336                         'Requires lcov 1.11 or later.\n\n'
    337                         'By default runs pdfium_unittests and '
    338                         'pdfium_embeddertests. If --slow is passed in then all '
    339                         'tests will be run. If any of the tests are specified '
    340                         'on the command line, then only those will be run.')
    341   parser.add_argument(
    342       '-s',
    343       '--source_directory',
    344       help='Location of PDFium source directory, defaults to CWD',
    345       default=os.getcwd())
    346   build_default = os.path.join('out', 'Coverage')
    347   parser.add_argument(
    348       '-b',
    349       '--build_directory',
    350       help=
    351       'Location of PDFium build directory with coverage enabled, defaults to '
    352       '%s under CWD' % build_default,
    353       default=os.path.join(os.getcwd(), build_default))
    354   output_default = 'coverage_report'
    355   parser.add_argument(
    356       '-o',
    357       '--output_directory',
    358       help='Location to write out coverage report to, defaults to %s under CWD '
    359       % output_default,
    360       default=os.path.join(os.getcwd(), output_default))
    361   parser.add_argument(
    362       '-n',
    363       '--dry-run',
    364       help='Output commands instead of executing them',
    365       action='store_true')
    366   parser.add_argument(
    367       '-v',
    368       '--verbose',
    369       help='Output additional diagnostic information',
    370       action='store_true')
    371   parser.add_argument(
    372       '--slow',
    373       help='Run all tests, even those known to take a long time. Ignored if '
    374       'specific tests are passed in.',
    375       action='store_true')
    376   parser.add_argument(
    377       'tests',
    378       help='Tests to be run, defaults to all. Valid entries are %s' %
    379       COVERAGE_TESTS.keys(),
    380       nargs='*')
    381 
    382   args = vars(parser.parse_args())
    383   if args['verbose']:
    384     pprint.pprint(args)
    385 
    386   executor = CoverageExecutor(parser, args)
    387   if executor.run():
    388     return 0
    389   return 1
    390 
    391 
    392 if __name__ == '__main__':
    393   sys.exit(main())
    394