| #!/usr/bin/env python | |
| # Copyright 2017 The PDFium Authors. All rights reserved. | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| """Generates a coverage report for given binaries using llvm-gcov & lcov. | |
| Requires llvm-cov 3.5 or later. | |
| Requires lcov 1.11 or later. | |
| Requires that 'use_coverage = true' is set in args.gn. | |
| """ | |
| import argparse | |
| from collections import namedtuple | |
| import os | |
| import pprint | |
| import re | |
| import subprocess | |
| import sys | |
| # Add src dir to path to avoid having to set PYTHONPATH. | |
| sys.path.append( | |
| os.path.abspath( | |
| os.path.join( | |
| os.path.dirname(__file__), | |
| os.path.pardir, | |
| os.path.pardir, | |
| os.path.pardir))) | |
| from testing.tools.common import GetBooleanGnArg | |
| # 'binary' is the file that is to be run for the test. | |
| # 'use_test_runner' indicates if 'binary' depends on test_runner.py and thus | |
| # requires special handling. | |
| TestSpec = namedtuple('TestSpec', 'binary, use_test_runner') | |
| # All of the coverage tests that the script knows how to run. | |
| COVERAGE_TESTS = { | |
| 'pdfium_unittests': TestSpec('pdfium_unittests', False), | |
| 'pdfium_embeddertests': TestSpec('pdfium_embeddertests', False), | |
| 'corpus_tests': TestSpec('run_corpus_tests.py', True), | |
| 'javascript_tests': TestSpec('run_javascript_tests.py', True), | |
| 'pixel_tests': TestSpec('run_pixel_tests.py', True), | |
| } | |
| # Coverage tests that are known to take a long time to run, so are not in the | |
| # default set. The user must either explicitly invoke these tests or pass in | |
| # --slow. | |
| SLOW_TESTS = ['corpus_tests', 'javascript_tests', 'pixel_tests'] | |
| class CoverageExecutor(object): | |
| def __init__(self, parser, args): | |
| """Initialize executor based on the current script environment | |
| Args: | |
| parser: argparse.ArgumentParser for handling improper inputs. | |
| args: Dictionary of arguments passed into the calling script. | |
| """ | |
| self.dry_run = args['dry_run'] | |
| self.verbose = args['verbose'] | |
| llvm_cov = self.determine_proper_llvm_cov() | |
| if not llvm_cov: | |
| print 'Unable to find appropriate llvm-cov to use' | |
| sys.exit(1) | |
| self.lcov_env = os.environ | |
| self.lcov_env['LLVM_COV_BIN'] = llvm_cov | |
| self.lcov = self.determine_proper_lcov() | |
| if not self.lcov: | |
| print 'Unable to find appropriate lcov to use' | |
| sys.exit(1) | |
| self.coverage_files = set() | |
| self.source_directory = args['source_directory'] | |
| if not os.path.isdir(self.source_directory): | |
| parser.error("'%s' needs to be a directory" % self.source_directory) | |
| self.build_directory = args['build_directory'] | |
| if not os.path.isdir(self.build_directory): | |
| parser.error("'%s' needs to be a directory" % self.build_directory) | |
| self.coverage_tests = self.calculate_coverage_tests(args) | |
| if not self.coverage_tests: | |
| parser.error( | |
| 'No valid tests in set to be run. This is likely due to bad command ' | |
| 'line arguments') | |
| if not GetBooleanGnArg('use_coverage', self.build_directory, self.verbose): | |
| parser.error( | |
| 'use_coverage does not appear to be set to true for build, but is ' | |
| 'needed') | |
| self.use_goma = GetBooleanGnArg('use_goma', self.build_directory, | |
| self.verbose) | |
| self.output_directory = args['output_directory'] | |
| if not os.path.exists(self.output_directory): | |
| if not self.dry_run: | |
| os.makedirs(self.output_directory) | |
| elif not os.path.isdir(self.output_directory): | |
| parser.error('%s exists, but is not a directory' % self.output_directory) | |
| self.coverage_totals_path = os.path.join(self.output_directory, | |
| 'pdfium_totals.info') | |
| def check_output(self, args, dry_run=False, env=None): | |
| """Dry run aware wrapper of subprocess.check_output()""" | |
| if dry_run: | |
| print "Would have run '%s'" % ' '.join(args) | |
| return '' | |
| output = subprocess.check_output(args, env=env) | |
| if self.verbose: | |
| print "check_output(%s) returned '%s'" % (args, output) | |
| return output | |
| def call(self, args, dry_run=False, env=None): | |
| """Dry run aware wrapper of subprocess.call()""" | |
| if dry_run: | |
| print "Would have run '%s'" % ' '.join(args) | |
| return 0 | |
| output = subprocess.call(args, env=env) | |
| if self.verbose: | |
| print 'call(%s) returned %s' % (args, output) | |
| return output | |
| def call_lcov(self, args, dry_run=False, needs_directory=True): | |
| """Wrapper to call lcov that adds appropriate arguments as needed.""" | |
| lcov_args = [ | |
| self.lcov, '--config-file', | |
| os.path.join(self.source_directory, 'testing', 'tools', 'coverage', | |
| 'lcovrc'), | |
| '--gcov-tool', | |
| os.path.join(self.source_directory, 'testing', 'tools', 'coverage', | |
| 'llvm-gcov') | |
| ] | |
| if needs_directory: | |
| lcov_args.extend(['--directory', self.source_directory]) | |
| if not self.verbose: | |
| lcov_args.append('--quiet') | |
| lcov_args.extend(args) | |
| return self.call(lcov_args, dry_run=dry_run, env=self.lcov_env) | |
| def calculate_coverage_tests(self, args): | |
| """Determine which tests should be run.""" | |
| testing_tools_directory = os.path.join(self.source_directory, 'testing', | |
| 'tools') | |
| coverage_tests = {} | |
| for name in COVERAGE_TESTS.keys(): | |
| test_spec = COVERAGE_TESTS[name] | |
| if test_spec.use_test_runner: | |
| binary_path = os.path.join(testing_tools_directory, test_spec.binary) | |
| else: | |
| binary_path = os.path.join(self.build_directory, test_spec.binary) | |
| coverage_tests[name] = TestSpec(binary_path, test_spec.use_test_runner) | |
| if args['tests']: | |
| return {name: spec | |
| for name, spec in coverage_tests.iteritems() if name in args['tests']} | |
| elif not args['slow']: | |
| return {name: spec | |
| for name, spec in coverage_tests.iteritems() if name not in SLOW_TESTS} | |
| else: | |
| return coverage_tests | |
| def find_acceptable_binary(self, binary_name, version_regex, | |
| min_major_version, min_minor_version): | |
| """Find the newest version of binary that meets the min version.""" | |
| min_version = (min_major_version, min_minor_version) | |
| parsed_versions = {} | |
| # When calling Bash builtins like this the command and arguments must be | |
| # passed in as a single string instead of as separate list members. | |
| potential_binaries = self.check_output( | |
| ['bash', '-c', 'compgen -abck %s' % binary_name]).splitlines() | |
| for binary in potential_binaries: | |
| if self.verbose: | |
| print 'Testing llvm-cov binary, %s' % binary | |
| # Assuming that scripts that don't respond to --version correctly are not | |
| # valid binaries and just happened to get globbed in. This is true for | |
| # lcov and llvm-cov | |
| try: | |
| version_output = self.check_output([binary, '--version']).splitlines() | |
| except subprocess.CalledProcessError: | |
| if self.verbose: | |
| print '--version returned failure status 1, so ignoring' | |
| continue | |
| for line in version_output: | |
| matcher = re.match(version_regex, line) | |
| if matcher: | |
| parsed_version = (int(matcher.group(1)), int(matcher.group(2))) | |
| if parsed_version >= min_version: | |
| parsed_versions[parsed_version] = binary | |
| break | |
| if not parsed_versions: | |
| return None | |
| return parsed_versions[max(parsed_versions)] | |
| def determine_proper_llvm_cov(self): | |
| """Find a version of llvm_cov that will work with the script.""" | |
| version_regex = re.compile('.*LLVM version ([\d]+)\.([\d]+).*') | |
| return self.find_acceptable_binary('llvm-cov', version_regex, 3, 5) | |
| def determine_proper_lcov(self): | |
| """Find a version of lcov that will work with the script.""" | |
| version_regex = re.compile('.*LCOV version ([\d]+)\.([\d]+).*') | |
| return self.find_acceptable_binary('lcov', version_regex, 1, 11) | |
| def build_binaries(self): | |
| """Build all the binaries that are going to be needed for coverage | |
| generation.""" | |
| call_args = ['ninja'] | |
| if self.use_goma: | |
| call_args.extend(['-j', '250']) | |
| call_args.extend(['-C', self.build_directory]) | |
| return self.call(call_args, dry_run=self.dry_run) == 0 | |
| def generate_coverage(self, name, spec): | |
| """Generate the coverage data for a test | |
| Args: | |
| name: Name associated with the test to be run. This is used as a label | |
| in the coverage data, so should be unique across all of the tests | |
| being run. | |
| spec: Tuple containing the path to the binary to run, and if this test | |
| uses test_runner.py. | |
| """ | |
| if self.verbose: | |
| print "Generating coverage for test '%s', using data '%s'" % (name, spec) | |
| if not os.path.exists(spec.binary): | |
| print('Unable to generate coverage for %s, since it appears to not exist' | |
| ' @ %s') % (name, spec.binary) | |
| return False | |
| if self.call_lcov(['--zerocounters'], dry_run=self.dry_run): | |
| print 'Unable to clear counters for %s' % name | |
| return False | |
| binary_args = [spec.binary] | |
| if spec.use_test_runner: | |
| # Test runner performs multi-threading in the wrapper script, not the test | |
| # binary, so need -j 1, otherwise multiple processes will be writing to | |
| # the code coverage files, invalidating results. | |
| # TODO(pdfium:811): Rewrite how test runner tests work, so that they can | |
| # be run in multi-threaded mode. | |
| binary_args.extend(['-j', '1', '--build-dir', self.build_directory]) | |
| if self.call(binary_args, dry_run=self.dry_run) and self.verbose: | |
| print('Running %s appears to have failed, which might affect ' | |
| 'results') % spec.binary | |
| output_raw_path = os.path.join(self.output_directory, '%s_raw.info' % name) | |
| if self.call_lcov( | |
| ['--capture', '--test-name', name, '--output-file', output_raw_path], | |
| dry_run=self.dry_run): | |
| print 'Unable to capture coverage data for %s' % name | |
| return False | |
| output_filtered_path = os.path.join(self.output_directory, | |
| '%s_filtered.info' % name) | |
| output_filters = [ | |
| '/usr/include/*', '*third_party*', '*testing*', '*_unittest.cpp', | |
| '*_embeddertest.cpp' | |
| ] | |
| if self.call_lcov( | |
| ['--remove', output_raw_path] + output_filters + | |
| ['--output-file', output_filtered_path], | |
| dry_run=self.dry_run, | |
| needs_directory=False): | |
| print 'Unable to filter coverage data for %s' % name | |
| return False | |
| self.coverage_files.add(output_filtered_path) | |
| return True | |
| def merge_coverage(self): | |
| """Merge all of the coverage data sets into one for report generation.""" | |
| merge_args = [] | |
| for coverage_file in self.coverage_files: | |
| merge_args.extend(['--add-tracefile', coverage_file]) | |
| merge_args.extend(['--output-file', self.coverage_totals_path]) | |
| return self.call_lcov( | |
| merge_args, dry_run=self.dry_run, needs_directory=False) == 0 | |
| def generate_report(self): | |
| """Produce HTML coverage report based on combined coverage data set.""" | |
| config_file = os.path.join( | |
| self.source_directory, 'testing', 'tools', 'coverage', 'lcovrc') | |
| lcov_args = ['genhtml', | |
| '--config-file', config_file, | |
| '--legend', | |
| '--demangle-cpp', | |
| '--show-details', | |
| '--prefix', self.source_directory, | |
| '--ignore-errors', | |
| 'source', self.coverage_totals_path, | |
| '--output-directory', self.output_directory] | |
| return self.call(lcov_args, dry_run=self.dry_run) == 0 | |
| def run(self): | |
| """Setup environment, execute the tests and generate coverage report""" | |
| if not self.build_binaries(): | |
| print 'Failed to successfully build binaries' | |
| return False | |
| for name in self.coverage_tests.keys(): | |
| if not self.generate_coverage(name, self.coverage_tests[name]): | |
| print 'Failed to successfully generate coverage data' | |
| return False | |
| if not self.merge_coverage(): | |
| print 'Failed to successfully merge generated coverage data' | |
| return False | |
| if not self.generate_report(): | |
| print 'Failed to successfully generated coverage report' | |
| return False | |
| return True | |
| def main(): | |
| parser = argparse.ArgumentParser() | |
| parser.formatter_class = argparse.RawDescriptionHelpFormatter | |
| parser.description = ('Generates a coverage report for given binaries using ' | |
| 'llvm-cov & lcov.\n\n' | |
| 'Requires llvm-cov 3.5 or later.\n' | |
| 'Requires lcov 1.11 or later.\n\n' | |
| 'By default runs pdfium_unittests and ' | |
| 'pdfium_embeddertests. If --slow is passed in then all ' | |
| 'tests will be run. If any of the tests are specified ' | |
| 'on the command line, then only those will be run.') | |
| parser.add_argument( | |
| '-s', | |
| '--source_directory', | |
| help='Location of PDFium source directory, defaults to CWD', | |
| default=os.getcwd()) | |
| build_default = os.path.join('out', 'Coverage') | |
| parser.add_argument( | |
| '-b', | |
| '--build_directory', | |
| help= | |
| 'Location of PDFium build directory with coverage enabled, defaults to ' | |
| '%s under CWD' % build_default, | |
| default=os.path.join(os.getcwd(), build_default)) | |
| output_default = 'coverage_report' | |
| parser.add_argument( | |
| '-o', | |
| '--output_directory', | |
| help='Location to write out coverage report to, defaults to %s under CWD ' | |
| % output_default, | |
| default=os.path.join(os.getcwd(), output_default)) | |
| parser.add_argument( | |
| '-n', | |
| '--dry-run', | |
| help='Output commands instead of executing them', | |
| action='store_true') | |
| parser.add_argument( | |
| '-v', | |
| '--verbose', | |
| help='Output additional diagnostic information', | |
| action='store_true') | |
| parser.add_argument( | |
| '--slow', | |
| help='Run all tests, even those known to take a long time. Ignored if ' | |
| 'specific tests are passed in.', | |
| action='store_true') | |
| parser.add_argument( | |
| 'tests', | |
| help='Tests to be run, defaults to all. Valid entries are %s' % | |
| COVERAGE_TESTS.keys(), | |
| nargs='*') | |
| args = vars(parser.parse_args()) | |
| if args['verbose']: | |
| pprint.pprint(args) | |
| executor = CoverageExecutor(parser, args) | |
| if executor.run(): | |
| return 0 | |
| return 1 | |
| if __name__ == '__main__': | |
| sys.exit(main()) |