| #!/usr/bin/python |
| # Copyright 2017 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Script to generate code coverage report for iOS. |
| |
| The generated code coverage report excludes test files, and test files are |
| identified by postfixes: ['unittest.cc', 'unittest.mm', 'egtest.mm']. |
| |
| NOTE: This script must be called from the root of checkout. It may take up to |
| a few minutes to generate a report for targets that depend on Chrome, |
| such as ios_chrome_unittests. To simply play with this tool, you are |
| suggested to start with 'url_unittests'. |
| |
| Example usages: |
| ios/tools/coverage/coverage.py url_unittests -t url/ -o out/html |
| # Generate code coverage report for url_unittests for directory url/ and all |
| # generated artifacts are stored in out/html |
| |
| ios/tools/coverage/coverage.py url_unittests -t url/ -i url/mojo -o out/html |
| # Only include files under url/mojo. |
| |
| ios/tools/coverage/coverage.py url_unittests -t url/ -i url/mojo -o out/html |
| -r coverage.profdata |
| # Skip running tests and reuse the specified profile data file. |
| |
| For more options, please refer to ios/tools/coverage/coverage.py -h |
| """ |
| import sys |
| |
| import argparse |
| import ConfigParser |
| import os |
| import subprocess |
| import webbrowser |
| |
| sys.path.append(os.path.join(os.path.dirname(__file__), os.path.pardir, |
| os.path.pardir, os.path.pardir, 'third_party')) |
| import jinja2 |
| |
| BUILD_DIRECTORY = 'out/Coverage-iphonesimulator' |
| DEFAULT_GOMA_JOBS = 50 |
| |
| # Name of the final profdata file, and this file needs to be passed to |
| # "llvm-cov" command in order to call "llvm-cov show" to inspect the |
| # line-by-line coverage of specific files. |
| PROFDATA_FILE_NAME = 'coverage.profdata' |
| |
| # The code coverage profraw data file is generated by running the tests with |
| # coverage configuration, and the path to the file is part of the log that can |
| # be identified with the following identifier. |
| PROFRAW_FILE_LOG_IDENTIFIER = 'Coverage data at ' |
| |
| # Only test targets with the following postfixes are considered to be valid. |
| VALID_TEST_TARGET_POSTFIXES = ['unittests', 'inttests', 'egtests'] |
| |
| # Used to determine if a test target is an earl grey test. |
| EARL_GREY_TEST_TARGET_POSTFIX = 'egtests' |
| |
| # Used to determine if a file is a test file. The coverage of test files should |
| # be excluded from code coverage report. |
| # TODO(crbug.com/763957): Make test file identifiers configurable. |
| TEST_FILES_POSTFIXES = ['unittest.mm', 'unittest.cc', 'egtest.mm'] |
| |
| # The default name of the html coverage report for a directory. |
| DIRECTORY_COVERAGE_HTML_REPORT_NAME = 'coverage.html' |
| |
| |
| class _DirectoryCoverageReportHtmlGenerator(object): |
| """Excapsulates code coverage html report generation for a directory. |
| |
| The generated html has a table that contains all the code coverage of its |
| sub-directories and files. Please refer to ./example.html for an example of |
| the generated html file. |
| """ |
| |
| def __init__(self, css_path): |
| """Initializes _DirectoryCoverageReportHtmlGenerator object. |
| |
| This class assumes that the css file generated by 'llvm-cov show' is reused. |
| |
| Args: |
| css_path: A path to the css file. |
| """ |
| assert os.path.exists(css_path), ('css file: {} doesn\'t exist.' |
| .format(css_path)) |
| self._css_path = css_path |
| self._table_entries = [] |
| template_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), |
| 'html_templates') |
| |
| jinja_env = jinja2.Environment(loader=jinja2.FileSystemLoader(template_dir), |
| trim_blocks=True) |
| self._header_template = jinja_env.get_template('header.html') |
| self._table_template = jinja_env.get_template('table.html') |
| self._footer_template = jinja_env.get_template('footer.html') |
| |
| def AddTableEntry(self, html_report_path, name, total_lines, executed_lines): |
| """Adds a file or directory entry to the directory html coverage report. |
| |
| Args: |
| html_report_path: A relative path to the file or directory's html report. |
| name: Base name of the file or directory. |
| total_lines: total number of lines. |
| executed_lines: executed number of lines. |
| """ |
| coverage = float(executed_lines) / total_lines |
| if coverage < 0.8: |
| color_class = 'column-entry-red' |
| elif coverage < 1: |
| color_class = 'column-entry-yellow' |
| elif coverage == 1: |
| color_class = 'column-entry-green' |
| else: |
| assert False, ('coverage cannot be greater than 100%, however, {} has ' |
| 'coverage: {}').format(html_report_path, coverage) |
| percentage_coverage = round(coverage * 100, 2) |
| |
| self._table_entries.append({'href': html_report_path, |
| 'name': name, |
| 'color_class': color_class, |
| 'total_lines': total_lines, |
| 'executed_lines': executed_lines, |
| 'percentage_coverage': percentage_coverage}) |
| |
| def WriteHtmlCoverageReport(self, output_path): |
| """Write html coverage report for the directory. |
| |
| In the report, sub-directories are displayed before files and within each |
| category, entries are sorted by coverage in ascending order. |
| |
| Args: |
| output_path: A path to the html report. |
| """ |
| dir_entries = [entry for entry in self._table_entries |
| if os.path.basename(entry['href']) == |
| DIRECTORY_COVERAGE_HTML_REPORT_NAME] |
| file_entries = [entry for entry in self._table_entries |
| if entry not in dir_entries] |
| |
| file_entries.sort( |
| key=lambda entry: float(entry['executed_lines']) / entry['total_lines']) |
| dir_entries.sort( |
| key=lambda entry: float(entry['executed_lines']) / entry['total_lines']) |
| |
| html_header = self._header_template.render( |
| css_path=os.path.relpath(self._css_path, os.path.dirname(output_path))) |
| html_table = self._table_template.render(dir_entries=dir_entries, |
| file_entries=file_entries) |
| html_footer = self._footer_template.render() |
| |
| with open(output_path, 'w') as html_file: |
| html_file.write(html_header + html_table + html_footer) |
| |
| |
| class _FileLineCoverageReport(object): |
| """Encapsulates coverage calculations for files.""" |
| |
| def __init__(self): |
| """Initializes FileLineCoverageReport object.""" |
| self._coverage = {} |
| |
| def AddFile(self, path, total_lines, executed_lines): |
| """Adds a new file entry. |
| |
| Args: |
| path: path to the file. |
| total_lines: Total number of lines. |
| executed_lines: Total number of executed lines. |
| """ |
| summary = { |
| 'total': total_lines, |
| 'executed': executed_lines |
| } |
| self._coverage[path] = summary |
| |
| def ContainsFile(self, path): |
| """Returns True if the path is in the report. |
| |
| Args: |
| path: path to the file. |
| |
| Returns: |
| True if the path is in the report. |
| """ |
| return path in self._coverage |
| |
| def GetCoverageForFile(self, path): |
| """Returns tuple representing coverage for a file. |
| |
| Args: |
| path: path to the file. |
| |
| Returns: |
| tuple with two integers (total number of lines, number of executed lines.) |
| """ |
| assert path in self._coverage, '{} is not in the report.'.format(path) |
| return self._coverage[path]['total'], self._coverage[path]['executed'] |
| |
| def GetListOfFiles(self): |
| """Returns a list of files in the report. |
| |
| Returns: |
| A list of files. |
| """ |
| return self._coverage.keys() |
| |
| def FilterFiles(self, include_paths, exclude_paths): |
| """Filter files in the report. |
| |
| Only includes files that is under at least one of the paths in |
| |include_paths|, but none of them in |exclude_paths|. |
| |
| Args: |
| include_paths: A list of directories and files. |
| exclude_paths: A list of directories and files. |
| """ |
| files_to_delete = [] |
| for path in self._coverage: |
| should_include = (any(os.path.abspath(path).startswith( |
| os.path.abspath(include_path)) |
| for include_path in include_paths)) |
| should_exclude = (any(os.path.abspath(path).startswith( |
| os.path.abspath(exclude_path)) |
| for exclude_path in exclude_paths)) |
| |
| if not should_include or should_exclude: |
| files_to_delete.append(path) |
| |
| for path in files_to_delete: |
| del self._coverage[path] |
| |
| def ExcludeTestFiles(self): |
| """Exclude test files from the report. |
| |
| Test files are identified by |TEST_FILES_POSTFIXES|. |
| """ |
| files_to_delete = [] |
| for path in self._coverage: |
| if any(path.endswith(postfix) for postfix in TEST_FILES_POSTFIXES): |
| files_to_delete.append(path) |
| |
| for path in files_to_delete: |
| del self._coverage[path] |
| |
| |
| class _DirectoryLineCoverageReport(object): |
| """Encapsulates coverage calculations for directories.""" |
| |
| def __init__(self, file_line_coverage_report, top_level_dir): |
| """Initializes DirectoryLineCoverageReport object.""" |
| self._coverage = {} |
| self._CalculateCoverageForDirectory(top_level_dir, self._coverage, |
| file_line_coverage_report) |
| |
| def _CalculateCoverageForDirectory(self, path, line_coverage_result, |
| file_line_coverage_report): |
| """Recursively calculates the line coverage for a directory. |
| |
| Only directories that have non-zero number of total lines are included in |
| the report. |
| |
| Args: |
| path: path to the directory. |
| line_coverage_result: per directory line coverage result with format: |
| dict => A dictionary containing line coverage data. |
| -- dir_path: dict => Line coverage summary. |
| ---- total: int => total number of lines. |
| ---- executed: int => executed number of lines. |
| file_line_coverage_report: a FileLineCoverageReport object. |
| """ |
| if path in line_coverage_result: |
| return |
| |
| sum_total_lines = 0 |
| sum_executed_lines = 0 |
| for sub_name in os.listdir(path): |
| sub_path = os.path.normpath(os.path.join(path, sub_name)) |
| if os.path.isdir(sub_path): |
| # Calculate coverage for sub-directories recursively. |
| self._CalculateCoverageForDirectory(sub_path, line_coverage_result, |
| file_line_coverage_report) |
| |
| if sub_path in line_coverage_result: |
| sum_total_lines += line_coverage_result[sub_path]['total'] |
| sum_executed_lines += line_coverage_result[sub_path]['executed'] |
| elif file_line_coverage_report.ContainsFile(sub_path): |
| total_lines, executed_lines = ( |
| file_line_coverage_report.GetCoverageForFile(sub_path)) |
| sum_total_lines += total_lines |
| sum_executed_lines += executed_lines |
| |
| if sum_total_lines != 0: |
| line_coverage_result[path] = {'total': sum_total_lines, |
| 'executed': sum_executed_lines} |
| |
| def GetListOfDirectories(self): |
| """Returns a list of directories in the report. |
| |
| Returns: |
| A list of directories. |
| """ |
| return self._coverage.keys() |
| |
| def ContainsDirectory(self, path): |
| """Returns True if the path is in the report. |
| |
| Args: |
| path: path to the directory. |
| |
| Returns: |
| True if the path is in the report. |
| """ |
| return path in self._coverage |
| |
| def GetCoverageForDirectory(self, path): |
| """Returns tuple representing coverage for a directory. |
| |
| Args: |
| path: path to the directory. |
| |
| Returns: |
| tuple with two integers (total number of lines, number of executed lines.) |
| """ |
| assert path in self._coverage, '{} is not in the report.'.format(path) |
| return self._coverage[path]['total'], self._coverage[path]['executed'] |
| |
| |
| def _GenerateLineByLineFileCoverageInHtml( |
| targets, profdata_file_path, file_line_coverage_report, output_dir): |
| """Generates per file line-by-line coverage in html using 'llvm-cov show'. |
| |
| For a file with absolute path /a/b/x.cc, a html report is generated as: |
| output_dir/coverage/a/b/x.cc.html. An index html file is also generated as: |
| output_dir/index.html. |
| |
| Args: |
| targets: A list of targets to be tested. |
| profdata_file_path: An absoluate path to the profdata file. |
| file_line_coverage_report: A FileLineCoverageReport object. |
| output_dir: output directory for generated html files, the path needs to be |
| an absolute path. |
| """ |
| assert os.path.isabs(output_dir), 'output_dir must be an absolute path.' |
| binary_paths = [os.path.join(_GetApplicationBundlePath(target), target) |
| for target in targets] |
| |
| # llvm-cov show [options] -instr-profile PROFILE BIN [-object BIN,...] |
| # [[-object BIN]] [SOURCES] |
| # NOTE: For object files, the first one is specified as a positional argument, |
| # and the rest are specified as keyword argument. |
| cmd = ['xcrun', 'llvm-cov', 'show', '-arch=x86_64', '-format=html', |
| '-show-expansions', '-output-dir=' + output_dir, |
| '-instr-profile=' + profdata_file_path, binary_paths[0]] |
| cmd.extend(['-object=' + binary_path for binary_path in binary_paths[1:]]) |
| cmd.extend(file_line_coverage_report.GetListOfFiles()) |
| |
| subprocess.check_call(cmd) |
| |
| |
| def _GeneratePerDirectoryCoverageHtmlReport(dir_line_coverage_report, |
| file_line_coverage_report, |
| output_dir): |
| """Generates per directory coverage report in html. |
| |
| For each directory, all its files or sub-directories that has non-zero number |
| of total lines are displayed. |
| |
| Args: |
| dir_line_coverage_report: A DirectoryLineCoverageReport object. |
| file_line_coverage_report: A FileLineCoverageReport object. |
| output_dir: output directory for generated html files, the path needs to be |
| an absolute path. |
| """ |
| assert os.path.isabs(output_dir), 'output_dir must be an absolute path.' |
| for dir_path in dir_line_coverage_report.GetListOfDirectories(): |
| _GenerateCoverageHtmlReportForDirectory(dir_path, dir_line_coverage_report, |
| file_line_coverage_report, |
| output_dir) |
| |
| |
| def _GenerateCoverageHtmlReportForDirectory(dir_path, dir_line_coverage_report, |
| file_line_coverage_report, |
| output_dir): |
| """Generates coverage report for directory in html. |
| |
| Args: |
| dir_path: A path to the directory. |
| dir_line_coverage_report: A DirectoryLineCoverageReport object. |
| file_line_coverage_report: A FileLineCoverageReport object. |
| output_dir: output directory for generated html files, the path needs to be |
| an absolute path. |
| """ |
| assert os.path.isabs(output_dir), 'output_dir must be an absolute path.' |
| css_path = os.path.join(output_dir, 'style.css') |
| html_generator = _DirectoryCoverageReportHtmlGenerator(css_path) |
| |
| for sub_name in os.listdir(dir_path): |
| sub_path = os.path.normpath(os.path.join(dir_path, sub_name)) |
| sub_path_html_report_path = _GetCoverageHtmlReportPath( |
| sub_path, output_dir) |
| relative_sub_path_html_report_path = os.path.relpath( |
| sub_path_html_report_path, |
| os.path.dirname(_GetCoverageHtmlReportPath(dir_path, output_dir))) |
| |
| if dir_line_coverage_report.ContainsDirectory(sub_path): |
| total_lines, executed_lines = ( |
| dir_line_coverage_report.GetCoverageForDirectory(sub_path)) |
| html_generator.AddTableEntry(relative_sub_path_html_report_path, |
| os.path.basename(sub_path), |
| total_lines, |
| executed_lines) |
| elif file_line_coverage_report.ContainsFile(sub_path): |
| total_lines, executed_lines = ( |
| file_line_coverage_report.GetCoverageForFile(sub_path)) |
| html_generator.AddTableEntry(relative_sub_path_html_report_path, |
| os.path.basename(sub_path), |
| total_lines, |
| executed_lines) |
| |
| html_generator.WriteHtmlCoverageReport( |
| _GetCoverageHtmlReportPath(dir_path, output_dir)) |
| |
| |
| def _GetCoverageHtmlReportPath(file_or_dir_path, output_dir): |
| """Given a file or directory, returns the corresponding html report path. |
| |
| Args: |
| file_or_dir_path: os path to a file or directory. |
| output_dir: output directory for generated html files, the path needs to be |
| an absolute path. |
| Returns: |
| A path to the corresponding coverage html report. |
| """ |
| assert os.path.isabs(output_dir), 'output_dir must be an absolute path.' |
| html_path = (os.path.join(os.path.abspath(output_dir), 'coverage') + |
| os.path.abspath(file_or_dir_path)) |
| if os.path.isdir(file_or_dir_path): |
| return os.path.join(html_path, DIRECTORY_COVERAGE_HTML_REPORT_NAME) |
| else: |
| return os.extsep.join([html_path, 'html']) |
| |
| |
| def _OverwriteHtmlReportsIndexFile(top_level_dir, dir_line_coverage_report, |
| output_dir): |
| """Overwrites the index file to link to the report of the top level directory. |
| |
| Args: |
| top_level_dir: The top level directory to generate code coverage for. |
| dir_line_coverage_report: A DirectoryLineCoverageReport object. |
| output_dir: output directory for generated html files, the path needs to be |
| an absolute path. |
| """ |
| assert os.path.isabs(output_dir), 'output_dir must be an absolute path.' |
| css_path = os.path.join(output_dir, 'style.css') |
| index_html_generator = _DirectoryCoverageReportHtmlGenerator(css_path) |
| |
| html_report_path = _GetCoverageHtmlReportPath(top_level_dir, |
| output_dir) |
| relative_html_report_path = os.path.relpath(html_report_path, output_dir) |
| total_lines, executed_lines = ( |
| dir_line_coverage_report.GetCoverageForDirectory(top_level_dir)) |
| index_html_generator.AddTableEntry( |
| relative_html_report_path, os.path.basename(top_level_dir), total_lines, |
| executed_lines) |
| |
| index_file_path = os.path.join(output_dir, 'index.html') |
| index_html_generator.WriteHtmlCoverageReport(index_file_path) |
| |
| |
| def _CreateCoverageProfileDataForTargets(targets, output_dir, jobs_count=None, |
| gtest_filter=None): |
| """Builds and runs target to generate the coverage profile data. |
| |
| Args: |
| targets: A list of targets to be tested. |
| output_dir: A directory to store created coverage profile data file, the |
| path needs to be an absolute path. |
| jobs_count: Number of jobs to run in parallel for building. If None, a |
| default value is derived based on CPUs availability. |
| gtest_filter: If present, only run unit tests whose full name matches the |
| filter. |
| |
| Returns: |
| An absolute path to the generated profdata file. |
| """ |
| assert os.path.isabs(output_dir), 'output_dir must be an absolute path.' |
| _BuildTargetsWithCoverageConfiguration(targets, jobs_count) |
| profraw_file_paths = _GetProfileRawDataPathsByRunningTargets(targets, |
| gtest_filter) |
| profdata_file_path = _CreateCoverageProfileDataFromProfRawData( |
| profraw_file_paths, output_dir) |
| |
| print 'Code coverage profile data is created as: ' + profdata_file_path |
| return os.path.abspath(profdata_file_path) |
| |
| |
| def _GeneratePerFileLineCoverageReport(targets, profdata_file_path): |
| """Generate per file code coverage report using llvm-cov report. |
| |
| The officially suggested command to export code coverage data is to use |
| "llvm-cov export", which returns comprehensive code coverage data in a json |
| object, however, due to the large size and complicated dependencies of |
| Chrome, "llvm-cov export" takes too long to run, and for example, it takes 5 |
| minutes for ios_chrome_unittests. Therefore, this script gets code coverage |
| data by calling "llvm-cov report", which is significantly faster and provides |
| the same data. |
| |
| The raw code coverage report returned from "llvm-cov report" has the following |
| format: |
| Filename\tRegions\tMissed Regions\tCover\tFunctions\tMissed Functions\t |
| Executed\tInstantiations\tMissed Insts.\tLines\tMissed Lines\tCover |
| ------------------------------------------------------------------------------ |
| ------------------------------------------------------------------------------ |
| base/at_exit.cc\t89\t85\t4.49%\t7\t6\t14.29%\t7\t6\t14.29%\t107\t99\t7.48% |
| url/pathurl.cc\t89\t85\t4.49%\t7\t6\t14.29%\t7\t6\t14.29%\t107\t99\t7.48% |
| ------------------------------------------------------------------------------ |
| ------------------------------------------------------------------------------ |
| In Total\t89\t85\t4.49%\t7\t6\t14.29%\t7\t6\t14.29%\t107\t99\t7.48% |
| |
| NOTE: This method only includes files with non-zero total number of lines to |
| the report. |
| |
| Args: |
| targets: A list of targets to be tested. |
| profdata_file_path: An absolute path to the profdata file. |
| |
| Returns: |
| A FileLineCoverageReport object. |
| """ |
| binary_paths = [os.path.join(_GetApplicationBundlePath(target), target) |
| for target in targets] |
| |
| # llvm-cov report [options] -instr-profile PROFILE BIN [-object BIN,...] |
| # [[-object BIN]] [SOURCES]. |
| # NOTE: For object files, the first one is specified as a positional argument, |
| # and the rest are specified as keyword argument. |
| cmd = ['xcrun', 'llvm-cov', 'report', '-arch=x86_64', |
| '-instr-profile=' + profdata_file_path, binary_paths[0]] |
| cmd.extend(['-object=' + binary_path for binary_path in binary_paths[1:]]) |
| |
| std_out = subprocess.check_output(cmd) |
| std_out_by_lines = std_out.split('\n') |
| |
| # Strip out the unrelated lines. The 1st line is the header and the 2nd line |
| # is a '-' separator line. The last line is an empty line break, the second |
| # to last line is the in total coverage and the third to last line is a '-' |
| # separator line. |
| coverage_content_by_files = std_out_by_lines[2: -3] |
| |
| # The 3rd to last column contains the total number of lines. |
| total_lines_index = -3 |
| |
| # The 2nd to last column contains the missed number of lines. |
| missed_lines_index = -2 |
| |
| file_line_coverage_report = _FileLineCoverageReport() |
| for coverage_content in coverage_content_by_files: |
| coverage_data = coverage_content.split() |
| file_name = coverage_data[0] |
| |
| # TODO(crbug.com/765818): llvm-cov has a bug that proceduces invalid data in |
| # the report, and the following hack works it around. Remove the hack once |
| # the bug is fixed. |
| try: |
| total_lines = int(coverage_data[total_lines_index]) |
| missed_lines = int(coverage_data[missed_lines_index]) |
| except ValueError: |
| continue |
| |
| if total_lines > 0: |
| executed_lines = total_lines - missed_lines |
| file_line_coverage_report.AddFile(file_name, total_lines, executed_lines) |
| |
| return file_line_coverage_report |
| |
| |
| def _PrintLineCoverageStats(total, executed): |
| """Print line coverage statistics. |
| |
| The format is as following: |
| Total Lines: 20 Executed Lines: 2 Missed lines: 18 Coverage: 10% |
| |
| Args: |
| total: total number of lines. |
| executed: number of lines that are executed. |
| """ |
| missed = total - executed |
| coverage = float(executed) / total if total > 0 else None |
| percentage_coverage = ('{}%'.format(int(coverage * 100)) |
| if coverage is not None else 'NA') |
| |
| output = ('Total Lines: {}\tExecuted Lines: {}\tMissed Lines: {}\t' |
| 'Coverage: {}\n') |
| print output.format(total, executed, missed, percentage_coverage) |
| |
| |
| def _BuildTargetsWithCoverageConfiguration(targets, jobs_count): |
| """Builds target with coverage configuration. |
| |
| This function requires current working directory to be the root of checkout. |
| |
| Args: |
| targets: A list of targets to be tested. |
| jobs_count: Number of jobs to run in parallel for compilation. If None, a |
| default value is derived based on CPUs availability. |
| """ |
| print 'Building ' + str(targets) |
| |
| src_root = _GetSrcRootPath() |
| build_dir_path = os.path.join(src_root, BUILD_DIRECTORY) |
| |
| cmd = ['ninja', '-C', build_dir_path] |
| if jobs_count: |
| cmd.append('-j' + str(jobs_count)) |
| |
| cmd.extend(targets) |
| subprocess.check_call(cmd) |
| |
| |
| def _GetProfileRawDataPathsByRunningTargets(targets, gtest_filter=None): |
| """Runs target and returns the path to the generated profraw data file. |
| |
| The output log of running the test target has no format, but it is guaranteed |
| to have a single line containing the path to the generated profraw data file. |
| |
| Args: |
| targets: A list of targets to be tested. |
| gtest_filter: If present, only run unit tests whose full name matches the |
| filter. |
| |
| Returns: |
| A list of absolute paths to the generated profraw data files. |
| """ |
| profraw_file_paths = [] |
| logs = _RunTestTargetsWithCoverageConfiguration(targets, gtest_filter) |
| |
| for log in logs: |
| is_profraw_file_found_in_log = False |
| for line in log: |
| if PROFRAW_FILE_LOG_IDENTIFIER in line: |
| is_profraw_file_found_in_log = True |
| profraw_file_path = line.split(PROFRAW_FILE_LOG_IDENTIFIER)[1][:-1] |
| profraw_file_paths.append(profraw_file_path) |
| break |
| |
| if not is_profraw_file_found_in_log: |
| assert False, ('No profraw data file is generated, did you call ' |
| 'coverage_util::ConfigureCoverageReportPath() in test ' |
| 'setup? Please refer to base/test/test_support_ios.mm for ' |
| 'example.') |
| |
| return profraw_file_paths |
| |
| |
| def _RunTestTargetsWithCoverageConfiguration(targets, gtest_filter=None): |
| """Runs tests to generate the profraw data file. |
| |
| This function requires current working directory to be the root of checkout. |
| |
| Args: |
| targets: A list of targets to be tested. |
| gtest_filter: If present, only run unit tests whose full name matches the |
| filter. |
| |
| Returns: |
| A list that contains the log output (after breaking by lines) for each |
| target. The logs has no format, but it is guaranteed to have a single line |
| containing the path to the generated profraw data file. |
| """ |
| logs = [] |
| iossim_path = _GetIOSSimPath() |
| for target in targets: |
| cmd = [iossim_path] |
| |
| # For iossim arguments, please refer to src/testing/iossim/iossim.mm. |
| if gtest_filter and not _TargetIsEarlGreyTest(target): |
| cmd.append('-c --gtest_filter=' + gtest_filter) |
| |
| application_path = _GetApplicationBundlePath(target) |
| cmd.append(application_path) |
| |
| if _TargetIsEarlGreyTest(target): |
| cmd.append(_GetXCTestBundlePath(target)) |
| |
| print 'Running {} with command: {}'.format(target, ' '.join(cmd)) |
| process = subprocess.Popen(cmd, stdout=subprocess.PIPE) |
| log_chracters, _ = process.communicate() |
| |
| logs.append(''.join(log_chracters).split('\n')) |
| return logs |
| |
| |
| def _CreateCoverageProfileDataFromProfRawData(profraw_file_paths, output_dir): |
| """Returns the path to the profdata file by merging profraw data file. |
| |
| Args: |
| profraw_file_paths: A list of absolute paths to the profraw data files that |
| are to be merged. |
| output_dir: A directory to store created coverage profile data file, the |
| path needs to be an absolute path. |
| |
| Returns: |
| An absolute path to the generated profdata file. |
| |
| Raises: |
| CalledProcessError: An error occurred merging profraw data files. |
| """ |
| print 'Creating the profile data file' |
| |
| assert os.path.isabs(output_dir), 'output_dir must be an absolute path.' |
| if not os.path.exists(output_dir): |
| os.makedirs(output_dir) |
| profdata_file_path = os.path.join(output_dir, PROFDATA_FILE_NAME) |
| try: |
| cmd = ['xcrun', 'llvm-profdata', 'merge', '-o', profdata_file_path] |
| cmd.extend(profraw_file_paths) |
| subprocess.check_call(cmd) |
| except subprocess.CalledProcessError as error: |
| print 'Failed to merge profraw to create profdata.' |
| raise error |
| |
| return profdata_file_path |
| |
| |
| def _GetSrcRootPath(): |
| """Returns the absolute path to the root of checkout. |
| |
| Returns: |
| A string representing the absolute path to the root of checkout. |
| """ |
| return os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir, |
| os.path.pardir, os.path.pardir)) |
| |
| |
| def _GetApplicationBundlePath(target): |
| """Returns the path to the generated application bundle after building. |
| |
| Args: |
| target: The target to be tested. |
| |
| Returns: |
| A path to the generated application bundles. |
| """ |
| src_root = _GetSrcRootPath() |
| application_bundle_name = target + '.app' |
| return os.path.join(src_root, BUILD_DIRECTORY, application_bundle_name) |
| |
| |
| def _GetXCTestBundlePath(target): |
| """Returns the path to the xctest bundle after building. |
| |
| Args: |
| target: A string representing the name of the target to be tested. |
| |
| Returns: |
| A string representing the path to the generated xctest bundle. |
| """ |
| application_path = _GetApplicationBundlePath(target) |
| xctest_bundle_name = target + '_module.xctest' |
| return os.path.join(application_path, 'PlugIns', xctest_bundle_name) |
| |
| |
| def _GetIOSSimPath(): |
| """Returns the path to the iossim executable file after building. |
| |
| Returns: |
| A string representing the path to the iossim executable file. |
| """ |
| src_root = _GetSrcRootPath() |
| iossim_path = os.path.join(src_root, BUILD_DIRECTORY, 'iossim') |
| return iossim_path |
| |
| |
| def _IsGomaConfigured(): |
| """Returns True if goma is enabled in the gn build settings. |
| |
| Returns: |
| A boolean indicates whether goma is configured for building or not. |
| """ |
| # Load configuration. |
| settings = ConfigParser.SafeConfigParser() |
| settings.read(os.path.expanduser('~/.setup-gn')) |
| return settings.getboolean('goma', 'enabled') |
| |
| |
| def _TargetIsEarlGreyTest(target): |
| """Returns true if the target is an earl grey test. |
| |
| Args: |
| target: A string representing the name of the target to be tested. |
| |
| Returns: |
| A boolean indicates whether the target is an earl grey test or not. |
| """ |
| return target.endswith(EARL_GREY_TEST_TARGET_POSTFIX) |
| |
| |
| def _AssertTargetsAreValidTestTargets(targets): |
| """Asserts that the targets have a valid postfix. |
| |
| The list of valid target name postfixes are defined in |
| VALID_TEST_TARGET_POSTFIXES. |
| |
| Args: |
| targets: A list of targets to be tested. |
| """ |
| for target in targets: |
| if not (any(target.endswith(postfix) for postfix in |
| VALID_TEST_TARGET_POSTFIXES)): |
| assert False, ('target: {} is detected, however, only target name with ' |
| 'the following postfixes are supported: {}'. |
| format(target, VALID_TEST_TARGET_POSTFIXES)) |
| |
| |
| def _AssertCoverageBuildDirectoryExists(): |
| """Asserts that the build directory with converage configuration exists.""" |
| src_root = _GetSrcRootPath() |
| build_dir_path = os.path.join(src_root, BUILD_DIRECTORY) |
| assert os.path.exists(build_dir_path), (build_dir_path + " doesn't exist." |
| 'Hint: run gclient runhooks or ' |
| 'ios/build/tools/setup-gn.py.') |
| |
| |
| def _AssertPathsExist(paths): |
| """Asserts that the paths specified in |paths| exist. |
| |
| Args: |
| paths: A list of files or directories. |
| """ |
| src_root = _GetSrcRootPath() |
| for path in paths: |
| abspath = os.path.join(src_root, path) |
| assert os.path.exists(abspath), (('Path: {} doesn\'t exist.\nA valid ' |
| 'path must exist and be relative to the ' |
| 'root of source, which is {}. For ' |
| 'example, \'ios/\' is a valid path.'). |
| format(abspath, src_root)) |
| |
| |
| def _ParseCommandArguments(): |
| """Add and parse relevant arguments for tool commands. |
| |
| Returns: |
| A dictionanry representing the arguments. |
| """ |
| arg_parser = argparse.ArgumentParser() |
| arg_parser.usage = __doc__ |
| |
| arg_parser.add_argument('-t', '--top-level-dir', type=str, required=True, |
| help='The top level directory to show code coverage ' |
| 'report, the path needs to be relative to the ' |
| 'root of the checkout.') |
| |
| arg_parser.add_argument('-i', '--include', action='append', |
| help='Directories to get code coverage for, and the ' |
| 'paths need to be relative to the root of the ' |
| 'checkout and all files under them are included ' |
| 'recursively.') |
| |
| arg_parser.add_argument('-e', '--exclude', action='append', |
| help='Directories to get code coverage for, and the ' |
| 'paths need to be relative to the root of the ' |
| 'checkout and all files under them are excluded ' |
| 'recursively.') |
| |
| arg_parser.add_argument('-j', '--jobs', type=int, default=None, |
| help='Run N jobs to build in parallel. If not ' |
| 'specified, a default value will be derived ' |
| 'based on CPUs availability. Please refer to ' |
| '\'ninja -h\' for more details.') |
| |
| arg_parser.add_argument('-r', '--reuse-profdata', type=str, |
| help='Skip building test target and running tests ' |
| 'and re-use the specified profile data file, ' |
| 'the path needs to be absolute or relative to ' |
| 'the root of checkout.') |
| |
| arg_parser.add_argument('-o', '--output-dir', type=str, required=True, |
| help='Output directory for generated artifacts, the' |
| 'path needs to be absolute or relative to the ' |
| 'root of checkout.') |
| |
| arg_parser.add_argument('--gtest_filter', type=str, |
| help='Only run unit tests whose full name matches ' |
| 'the filter.') |
| |
| arg_parser.add_argument('targets', nargs='+', |
| help='The names of the test targets to run.') |
| |
| args = arg_parser.parse_args() |
| return args |
| |
| |
| def Main(): |
| """Executes tool commands.""" |
| args = _ParseCommandArguments() |
| |
| _AssertTargetsAreValidTestTargets(args.targets) |
| jobs = args.jobs |
| if not jobs and _IsGomaConfigured(): |
| jobs = DEFAULT_GOMA_JOBS |
| |
| _AssertCoverageBuildDirectoryExists() |
| _AssertPathsExist([args.top_level_dir]) |
| |
| include_paths = args.include or [] |
| exclude_paths = args.exclude or [] |
| if include_paths: |
| _AssertPathsExist(include_paths) |
| if exclude_paths: |
| _AssertPathsExist(exclude_paths) |
| |
| if not include_paths: |
| include_paths.append(args.top_level_dir) |
| |
| output_dir_abspath = os.path.abspath(args.output_dir) |
| profdata_file_path = args.reuse_profdata |
| if profdata_file_path: |
| assert os.path.exists(profdata_file_path), (('The provided profile data ' |
| 'file: {} doesn\'t exist.') |
| .format(profdata_file_path)) |
| else: |
| profdata_file_path = _CreateCoverageProfileDataForTargets( |
| args.targets, output_dir_abspath, jobs, args.gtest_filter) |
| |
| print 'Generating code coverge report' |
| file_line_coverage_report = _GeneratePerFileLineCoverageReport( |
| args.targets, profdata_file_path) |
| file_line_coverage_report.FilterFiles(include_paths, exclude_paths) |
| file_line_coverage_report.ExcludeTestFiles() |
| |
| # ios/chrome and ios/chrome/ refer to the same directory. |
| top_level_dir = os.path.normpath(args.top_level_dir) |
| |
| dir_line_coverage_report = _DirectoryLineCoverageReport( |
| file_line_coverage_report, top_level_dir) |
| |
| print '\nLine Coverage Report for: ' + top_level_dir |
| total, executed = dir_line_coverage_report.GetCoverageForDirectory( |
| top_level_dir) |
| _PrintLineCoverageStats(total, executed) |
| |
| _GenerateLineByLineFileCoverageInHtml(args.targets, profdata_file_path, |
| file_line_coverage_report, |
| output_dir_abspath) |
| |
| print 'Generating per directory code coverage breakdown in html' |
| _GeneratePerDirectoryCoverageHtmlReport( |
| dir_line_coverage_report, file_line_coverage_report, output_dir_abspath) |
| |
| # The default index file is generated only for the list of source files, needs |
| # to overwrite it to display per directory code coverage breakdown. |
| _OverwriteHtmlReportsIndexFile(top_level_dir, dir_line_coverage_report, |
| output_dir_abspath) |
| html_index_file_path = 'file://' + os.path.abspath( |
| os.path.join(args.output_dir, 'index.html')) |
| print 'index file for html report is generated as: {}'.format( |
| html_index_file_path) |
| webbrowser.open(html_index_file_path) |
| |
| if __name__ == '__main__': |
| sys.exit(Main()) |