diff options
Diffstat (limited to 'testing/mozharness/mozharness/mozilla/testing/codecoverage.py')
-rw-r--r-- | testing/mozharness/mozharness/mozilla/testing/codecoverage.py | 679 |
1 files changed, 679 insertions, 0 deletions
diff --git a/testing/mozharness/mozharness/mozilla/testing/codecoverage.py b/testing/mozharness/mozharness/mozilla/testing/codecoverage.py new file mode 100644 index 0000000000..fd850324ed --- /dev/null +++ b/testing/mozharness/mozharness/mozilla/testing/codecoverage.py @@ -0,0 +1,679 @@ +#!/usr/bin/env python +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + +import errno +import json +import os +import posixpath +import shutil +import sys +import tempfile +import uuid +import zipfile + +import mozinfo + +from mozharness.base.script import PostScriptAction, PreScriptAction +from mozharness.mozilla.testing.per_test_base import SingleTestMixin + +code_coverage_config_options = [ + [ + ["--code-coverage"], + { + "action": "store_true", + "dest": "code_coverage", + "default": False, + "help": "Whether gcov c++ code coverage should be run.", + }, + ], + [ + ["--per-test-coverage"], + { + "action": "store_true", + "dest": "per_test_coverage", + "default": False, + "help": "Whether per-test coverage should be collected.", + }, + ], + [ + ["--disable-ccov-upload"], + { + "action": "store_true", + "dest": "disable_ccov_upload", + "default": False, + "help": "Whether test run should package and upload code coverage data.", + }, + ], + [ + ["--java-code-coverage"], + { + "action": "store_true", + "dest": "java_code_coverage", + "default": False, + "help": "Whether Java code coverage should be run.", + }, + ], +] + + +class CodeCoverageMixin(SingleTestMixin): + """ + Mixin for setting GCOV_PREFIX during test execution, packaging up + the resulting .gcda files and uploading them to blobber. + """ + + gcov_dir = None + grcov_dir = None + grcov_bin = None + jsvm_dir = None + prefix = None + per_test_reports = {} + + def __init__(self, **kwargs): + if mozinfo.os == "linux" or mozinfo.os == "mac": + self.grcov_bin = "grcov" + elif mozinfo.os == "win": + self.grcov_bin = "grcov.exe" + else: + raise Exception("Unexpected OS: {}".format(mozinfo.os)) + + super(CodeCoverageMixin, self).__init__(**kwargs) + + @property + def code_coverage_enabled(self): + try: + return bool(self.config.get("code_coverage")) + except (AttributeError, KeyError, TypeError): + return False + + @property + def per_test_coverage(self): + try: + return bool(self.config.get("per_test_coverage")) + except (AttributeError, KeyError, TypeError): + return False + + @property + def ccov_upload_disabled(self): + try: + return bool(self.config.get("disable_ccov_upload")) + except (AttributeError, KeyError, TypeError): + return False + + @property + def jsd_code_coverage_enabled(self): + try: + return bool(self.config.get("jsd_code_coverage")) + except (AttributeError, KeyError, TypeError): + return False + + @property + def java_code_coverage_enabled(self): + try: + return bool(self.config.get("java_code_coverage")) + except (AttributeError, KeyError, TypeError): + return False + + def _setup_cpp_js_coverage_tools(self): + fetches_dir = os.environ["MOZ_FETCHES_DIR"] + with open(os.path.join(fetches_dir, "target.mozinfo.json"), "r") as f: + build_mozinfo = json.load(f) + + self.prefix = build_mozinfo["topsrcdir"] + + strip_count = len(list(filter(None, self.prefix.split("/")))) + os.environ["GCOV_PREFIX_STRIP"] = str(strip_count) + + # Download the gcno archive from the build machine. + url_to_gcno = self.query_build_dir_url("target.code-coverage-gcno.zip") + self.download_file(url_to_gcno, parent_dir=self.grcov_dir) + + # Download the chrome-map.json file from the build machine. + url_to_chrome_map = self.query_build_dir_url("chrome-map.json") + self.download_file(url_to_chrome_map, parent_dir=self.grcov_dir) + + def _setup_java_coverage_tools(self): + # Download and extract jacoco-cli from the build task. + url_to_jacoco = self.query_build_dir_url("target.jacoco-cli.jar") + self.jacoco_jar = os.path.join(tempfile.mkdtemp(), "target.jacoco-cli.jar") + self.download_file(url_to_jacoco, self.jacoco_jar) + + # Download and extract class files from the build task. + self.classfiles_dir = tempfile.mkdtemp() + for archive in ["target.geckoview_classfiles.zip", "target.app_classfiles.zip"]: + url_to_classfiles = self.query_build_dir_url(archive) + classfiles_zip_path = os.path.join(self.classfiles_dir, archive) + self.download_file(url_to_classfiles, classfiles_zip_path) + with zipfile.ZipFile(classfiles_zip_path, "r") as z: + z.extractall(self.classfiles_dir) + os.remove(classfiles_zip_path) + + # Create the directory where the emulator coverage file will be placed. + self.java_coverage_output_dir = tempfile.mkdtemp() + + @PostScriptAction("download-and-extract") + def setup_coverage_tools(self, action, success=None): + if not self.code_coverage_enabled and not self.java_code_coverage_enabled: + return + + self.grcov_dir = os.path.join(os.environ["MOZ_FETCHES_DIR"], "grcov") + if not os.path.isfile(os.path.join(self.grcov_dir, self.grcov_bin)): + raise Exception( + "File not found: {}".format( + os.path.join(self.grcov_dir, self.grcov_bin) + ) + ) + + if self.code_coverage_enabled: + self._setup_cpp_js_coverage_tools() + + if self.java_code_coverage_enabled: + self._setup_java_coverage_tools() + + @PostScriptAction("download-and-extract") + def find_tests_for_coverage(self, action, success=None): + """ + For each file modified on this push, determine if the modified file + is a test, by searching test manifests. Populate self.verify_suites + with test files, organized by suite. + + This depends on test manifests, so can only run after test zips have + been downloaded and extracted. + """ + if not self.per_test_coverage: + return + + self.find_modified_tests() + + # TODO: Add tests that haven't been run for a while (a week? N pushes?) + + # Add baseline code coverage collection tests + baseline_tests_by_ext = { + ".html": { + "test": "testing/mochitest/baselinecoverage/plain/test_baselinecoverage.html", + "suite": "mochitest-plain", + }, + ".js": { + "test": "testing/mochitest/baselinecoverage/browser_chrome/browser_baselinecoverage.js", # NOQA: E501 + "suite": "mochitest-browser-chrome", + }, + ".xhtml": { + "test": "testing/mochitest/baselinecoverage/chrome/test_baselinecoverage.xhtml", + "suite": "mochitest-chrome", + }, + } + + baseline_tests_by_suite = { + "mochitest-browser-chrome": "testing/mochitest/baselinecoverage/browser_chrome/" + "browser_baselinecoverage_browser-chrome.js" + } + + wpt_baseline_test = "tests/web-platform/mozilla/tests/baselinecoverage/wpt_baselinecoverage.html" # NOQA: E501 + if self.config.get("per_test_category") == "web-platform": + if "testharness" not in self.suites: + self.suites["testharness"] = [] + if wpt_baseline_test not in self.suites["testharness"]: + self.suites["testharness"].append(wpt_baseline_test) + return + + # Go through all the tests and find all + # the baseline tests that are needed. + tests_to_add = {} + for suite in self.suites: + if len(self.suites[suite]) == 0: + continue + if suite in baseline_tests_by_suite: + if suite not in tests_to_add: + tests_to_add[suite] = [] + tests_to_add[suite].append(baseline_tests_by_suite[suite]) + continue + + # Default to file types if the suite has no baseline + for test in self.suites[suite]: + _, test_ext = os.path.splitext(test) + + if test_ext not in baseline_tests_by_ext: + # Add the '.js' test as a default baseline + # if none other exists. + test_ext = ".js" + baseline_test_suite = baseline_tests_by_ext[test_ext]["suite"] + baseline_test_name = baseline_tests_by_ext[test_ext]["test"] + + if baseline_test_suite not in tests_to_add: + tests_to_add[baseline_test_suite] = [] + if baseline_test_name not in tests_to_add[baseline_test_suite]: + tests_to_add[baseline_test_suite].append(baseline_test_name) + + # Add all baseline tests needed + for suite in tests_to_add: + for test in tests_to_add[suite]: + if suite not in self.suites: + self.suites[suite] = [] + if test not in self.suites[suite]: + self.suites[suite].append(test) + + @property + def coverage_args(self): + return [] + + def set_coverage_env(self, env, is_baseline_test=False): + # Set the GCOV directory. + self.gcov_dir = tempfile.mkdtemp() + env["GCOV_PREFIX"] = self.gcov_dir + + # Set the GCOV/JSVM directories where counters will be dumped in per-test mode. + if self.per_test_coverage and not is_baseline_test: + env["GCOV_RESULTS_DIR"] = tempfile.mkdtemp() + env["JSVM_RESULTS_DIR"] = tempfile.mkdtemp() + + # Set JSVM directory. + self.jsvm_dir = tempfile.mkdtemp() + env["JS_CODE_COVERAGE_OUTPUT_DIR"] = self.jsvm_dir + + @PreScriptAction("run-tests") + def _set_gcov_prefix(self, action): + if not self.code_coverage_enabled: + return + + if self.per_test_coverage: + return + + self.set_coverage_env(os.environ) + + def parse_coverage_artifacts( + self, + gcov_dir, + jsvm_dir, + merge=False, + output_format="lcov", + filter_covered=False, + ): + jsvm_output_file = "jsvm_lcov_output.info" + grcov_output_file = "grcov_lcov_output.info" + + dirs = self.query_abs_dirs() + + sys.path.append(dirs["abs_test_install_dir"]) + sys.path.append(os.path.join(dirs["abs_test_install_dir"], "mozbuild")) + + from codecoverage.lcov_rewriter import LcovFileRewriter + + jsvm_files = [os.path.join(jsvm_dir, e) for e in os.listdir(jsvm_dir)] + rewriter = LcovFileRewriter(os.path.join(self.grcov_dir, "chrome-map.json")) + rewriter.rewrite_files(jsvm_files, jsvm_output_file, "") + + # Run grcov on the zipped .gcno and .gcda files. + grcov_command = [ + os.path.join(self.grcov_dir, self.grcov_bin), + "-t", + output_format, + "-p", + self.prefix, + "--ignore", + "**/fetches/*", + os.path.join(self.grcov_dir, "target.code-coverage-gcno.zip"), + gcov_dir, + ] + + if "coveralls" in output_format: + grcov_command += ["--token", "UNUSED", "--commit-sha", "UNUSED"] + + if merge: + grcov_command += [jsvm_output_file] + + if mozinfo.os == "win" or mozinfo.os == "mac": + grcov_command += ["--llvm"] + + if filter_covered: + grcov_command += ["--filter", "covered"] + + def skip_cannot_normalize(output_to_filter): + return "\n".join( + line + for line in output_to_filter.rstrip().splitlines() + if "cannot be normalized because" not in line + ) + + # 'grcov_output' will be a tuple, the first variable is the path to the lcov output, + # the other is the path to the standard error output. + tmp_output_file, _ = self.get_output_from_command( + grcov_command, + silent=True, + save_tmpfiles=True, + return_type="files", + throw_exception=True, + output_filter=skip_cannot_normalize, + ) + shutil.move(tmp_output_file, grcov_output_file) + + shutil.rmtree(gcov_dir) + shutil.rmtree(jsvm_dir) + + if merge: + os.remove(jsvm_output_file) + return grcov_output_file + else: + return grcov_output_file, jsvm_output_file + + def add_per_test_coverage_report(self, env, suite, test): + gcov_dir = ( + env["GCOV_RESULTS_DIR"] if "GCOV_RESULTS_DIR" in env else self.gcov_dir + ) + jsvm_dir = ( + env["JSVM_RESULTS_DIR"] if "JSVM_RESULTS_DIR" in env else self.jsvm_dir + ) + + grcov_file = self.parse_coverage_artifacts( + gcov_dir, + jsvm_dir, + merge=True, + output_format="coveralls", + filter_covered=True, + ) + + report_file = str(uuid.uuid4()) + ".json" + shutil.move(grcov_file, report_file) + + # Get the test path relative to topsrcdir. + # This mapping is constructed by self.find_modified_tests(). + test = self.test_src_path.get(test.replace(os.sep, posixpath.sep), test) + + # Log a warning if the test path is still an absolute path. + if os.path.isabs(test): + self.warn("Found absolute path for test: {}".format(test)) + + if suite not in self.per_test_reports: + self.per_test_reports[suite] = {} + assert test not in self.per_test_reports[suite] + self.per_test_reports[suite][test] = report_file + + if "GCOV_RESULTS_DIR" in env: + assert "JSVM_RESULTS_DIR" in env + # In this case, parse_coverage_artifacts has removed GCOV_RESULTS_DIR and + # JSVM_RESULTS_DIR so we need to remove GCOV_PREFIX and JS_CODE_COVERAGE_OUTPUT_DIR. + try: + shutil.rmtree(self.gcov_dir) + except FileNotFoundError: + pass + + try: + shutil.rmtree(self.jsvm_dir) + except FileNotFoundError: + pass + + def is_covered(self, sf): + # For C/C++ source files, we can consider a file as being uncovered + # when all its source lines are uncovered. + all_lines_uncovered = all(c is None or c == 0 for c in sf["coverage"]) + if all_lines_uncovered: + return False + + # For JavaScript files, we can't do the same, as the top-level is always + # executed, even if it just contains declarations. So, we need to check if + # all its functions, except the top-level, are uncovered. + functions = sf["functions"] if "functions" in sf else [] + all_functions_uncovered = all( + not f["exec"] or f["name"] == "top-level" for f in functions + ) + if all_functions_uncovered and len(functions) > 1: + return False + + return True + + @PostScriptAction("run-tests") + def _package_coverage_data(self, action, success=None): + dirs = self.query_abs_dirs() + + if not self.code_coverage_enabled: + return + + if self.per_test_coverage: + if not self.per_test_reports: + self.info("No tests were found...not saving coverage data.") + return + + # Get the baseline tests that were run. + baseline_tests_ext_cov = {} + baseline_tests_suite_cov = {} + for suite, data in self.per_test_reports.items(): + for test, grcov_file in data.items(): + if "baselinecoverage" not in test: + continue + + # TODO: Optimize this part which loads JSONs + # with a size of about 40Mb into memory for diffing later. + # Bug 1460064 is filed for this. + with open(grcov_file, "r") as f: + data = json.load(f) + + if suite in os.path.split(test)[-1]: + baseline_tests_suite_cov[suite] = data + else: + _, baseline_filetype = os.path.splitext(test) + baseline_tests_ext_cov[baseline_filetype] = data + + dest = os.path.join( + dirs["abs_blob_upload_dir"], "per-test-coverage-reports.zip" + ) + with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z: + for suite, data in self.per_test_reports.items(): + for test, grcov_file in data.items(): + if "baselinecoverage" in test: + # Don't keep the baseline coverage + continue + else: + # Get test coverage + with open(grcov_file, "r") as f: + report = json.load(f) + + # Remove uncovered files, as they are unneeded for per-test + # coverage purposes. + report["source_files"] = [ + sf + for sf in report["source_files"] + if self.is_covered(sf) + ] + + # Get baseline coverage + baseline_coverage = {} + if suite in baseline_tests_suite_cov: + baseline_coverage = baseline_tests_suite_cov[suite] + elif self.config.get("per_test_category") == "web-platform": + baseline_coverage = baseline_tests_ext_cov[".html"] + else: + for file_type in baseline_tests_ext_cov: + if not test.endswith(file_type): + continue + baseline_coverage = baseline_tests_ext_cov[ + file_type + ] + break + + if not baseline_coverage: + # Default to the '.js' baseline as it is the largest + self.info("Did not find a baseline test for: " + test) + baseline_coverage = baseline_tests_ext_cov[".js"] + + unique_coverage = rm_baseline_cov(baseline_coverage, report) + + with open(grcov_file, "w") as f: + json.dump( + { + "test": test, + "suite": suite, + "report": unique_coverage, + }, + f, + ) + + z.write(grcov_file) + return + + del os.environ["GCOV_PREFIX_STRIP"] + del os.environ["GCOV_PREFIX"] + del os.environ["JS_CODE_COVERAGE_OUTPUT_DIR"] + + if not self.ccov_upload_disabled: + grcov_output_file, jsvm_output_file = self.parse_coverage_artifacts( + self.gcov_dir, self.jsvm_dir + ) + + try: + os.makedirs(dirs["abs_blob_upload_dir"]) + except OSError as e: + if e.errno != errno.EEXIST: + raise + + # Zip the grcov output and upload it. + grcov_zip_path = os.path.join( + dirs["abs_blob_upload_dir"], "code-coverage-grcov.zip" + ) + with zipfile.ZipFile(grcov_zip_path, "w", zipfile.ZIP_DEFLATED) as z: + z.write(grcov_output_file) + + # Zip the JSVM coverage data and upload it. + jsvm_zip_path = os.path.join( + dirs["abs_blob_upload_dir"], "code-coverage-jsvm.zip" + ) + with zipfile.ZipFile(jsvm_zip_path, "w", zipfile.ZIP_DEFLATED) as z: + z.write(jsvm_output_file) + + shutil.rmtree(self.grcov_dir) + + @PostScriptAction("run-tests") + def process_java_coverage_data(self, action, success=None): + """ + Run JaCoCo on the coverage.ec file in order to get a XML report. + After that, run grcov on the XML report to get a lcov report. + Finally, archive the lcov file and upload it, as process_coverage_data is doing. + """ + if not self.java_code_coverage_enabled: + return + + # If the emulator became unresponsive, the task has failed and we don't + # have any coverage report file, so stop running this function and + # allow the task to be retried automatically. + if not success and not os.listdir(self.java_coverage_output_dir): + return + + report_files = [ + os.path.join(self.java_coverage_output_dir, f) + for f in os.listdir(self.java_coverage_output_dir) + ] + assert len(report_files) > 0, "JaCoCo coverage data files were not found." + + dirs = self.query_abs_dirs() + xml_path = tempfile.mkdtemp() + jacoco_command = ( + ["java", "-jar", self.jacoco_jar, "report"] + + report_files + + [ + "--classfiles", + self.classfiles_dir, + "--name", + "geckoview-junit", + "--xml", + os.path.join(xml_path, "geckoview-junit.xml"), + ] + ) + self.run_command(jacoco_command, halt_on_failure=True) + + grcov_command = [ + os.path.join(self.grcov_dir, self.grcov_bin), + "-t", + "lcov", + xml_path, + ] + tmp_output_file, _ = self.get_output_from_command( + grcov_command, + silent=True, + save_tmpfiles=True, + return_type="files", + throw_exception=True, + ) + + if not self.ccov_upload_disabled: + grcov_zip_path = os.path.join( + dirs["abs_blob_upload_dir"], "code-coverage-grcov.zip" + ) + with zipfile.ZipFile(grcov_zip_path, "w", zipfile.ZIP_DEFLATED) as z: + z.write(tmp_output_file, "grcov_lcov_output.info") + + +def rm_baseline_cov(baseline_coverage, test_coverage): + """ + Returns the difference between test_coverage and + baseline_coverage, such that what is returned + is the unique coverage for the test in question. + """ + + # Get all files into a quicker search format + unique_test_coverage = test_coverage + baseline_files = {el["name"]: el for el in baseline_coverage["source_files"]} + test_files = {el["name"]: el for el in test_coverage["source_files"]} + + # Perform the difference and find everything + # unique to the test. + unique_file_coverage = {} + for test_file in test_files: + if test_file not in baseline_files: + unique_file_coverage[test_file] = test_files[test_file] + continue + + if len(test_files[test_file]["coverage"]) != len( + baseline_files[test_file]["coverage"] + ): + # File has line number differences due to gcov bug: + # https://bugzilla.mozilla.org/show_bug.cgi?id=1410217 + continue + + # TODO: Attempt to rewrite this section to remove one of the two + # iterations over a test's source file's coverage for optimization. + # Bug 1460064 was filed for this. + + # Get line numbers and the differences + file_coverage = { + i + for i, cov in enumerate(test_files[test_file]["coverage"]) + if cov is not None and cov > 0 + } + + baseline = { + i + for i, cov in enumerate(baseline_files[test_file]["coverage"]) + if cov is not None and cov > 0 + } + + unique_coverage = file_coverage - baseline + + if len(unique_coverage) > 0: + unique_file_coverage[test_file] = test_files[test_file] + + # Return the data to original format to return + # coverage within the test_coverge data object. + fmt_unique_coverage = [] + for i, cov in enumerate(unique_file_coverage[test_file]["coverage"]): + if cov is None: + fmt_unique_coverage.append(None) + continue + + # TODO: Bug 1460061, determine if hit counts + # need to be considered. + if cov > 0: + # If there is a count + if i in unique_coverage: + # Only add the count if it's unique + fmt_unique_coverage.append( + unique_file_coverage[test_file]["coverage"][i] + ) + continue + # Zero out everything that is not unique + fmt_unique_coverage.append(0) + unique_file_coverage[test_file]["coverage"] = fmt_unique_coverage + + # Reformat to original test_coverage list structure + unique_test_coverage["source_files"] = list(unique_file_coverage.values()) + + return unique_test_coverage |