# 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 datetime import errno import json import os import posixpath import re import subprocess from collections import defaultdict import mozpack.path as mozpath import requests import six.moves.urllib_parse as urlparse from mozbuild.base import MachCommandConditions as conditions from mozbuild.base import MozbuildObject from mozfile import which from moztest.resolve import TestManifestLoader, TestResolver REFERER = "https://wiki.developer.mozilla.org/en-US/docs/Mozilla/Test-Info" class TestInfo(object): """ Support 'mach test-info'. """ def __init__(self, verbose): self.verbose = verbose here = os.path.abspath(os.path.dirname(__file__)) self.build_obj = MozbuildObject.from_environment(cwd=here) def log_verbose(self, what): if self.verbose: print(what) class TestInfoTests(TestInfo): """ Support 'mach test-info tests': Detailed report of specified tests. """ def __init__(self, verbose): TestInfo.__init__(self, verbose) self._hg = None if conditions.is_hg(self.build_obj): self._hg = which("hg") if not self._hg: raise OSError(errno.ENOENT, "Could not find 'hg' on PATH.") self._git = None if conditions.is_git(self.build_obj): self._git = which("git") if not self._git: raise OSError(errno.ENOENT, "Could not find 'git' on PATH.") def find_in_hg_or_git(self, test_name): if self._hg: cmd = [self._hg, "files", "-I", test_name] elif self._git: cmd = [self._git, "ls-files", test_name] else: return None try: out = subprocess.check_output(cmd, universal_newlines=True).splitlines() except subprocess.CalledProcessError: out = None return out def set_test_name(self): # Generating a unified report for a specific test is complicated # by differences in the test name used in various data sources. # Consider: # - It is often convenient to request a report based only on # a short file name, rather than the full path; # - Bugs may be filed in bugzilla against a simple, short test # name or the full path to the test; # This function attempts to find appropriate names for different # queries based on the specified test name. # full_test_name is full path to file in hg (or git) self.full_test_name = None out = self.find_in_hg_or_git(self.test_name) if out and len(out) == 1: self.full_test_name = out[0] elif out and len(out) > 1: print("Ambiguous test name specified. Found:") for line in out: print(line) else: out = self.find_in_hg_or_git("**/%s*" % self.test_name) if out and len(out) == 1: self.full_test_name = out[0] elif out and len(out) > 1: print("Ambiguous test name. Found:") for line in out: print(line) if self.full_test_name: self.full_test_name.replace(os.sep, posixpath.sep) print("Found %s in source control." % self.full_test_name) else: print("Unable to validate test name '%s'!" % self.test_name) self.full_test_name = self.test_name # search for full_test_name in test manifests here = os.path.abspath(os.path.dirname(__file__)) resolver = TestResolver.from_environment( cwd=here, loader_cls=TestManifestLoader ) relpath = self.build_obj._wrap_path_argument(self.full_test_name).relpath() tests = list(resolver.resolve_tests(paths=[relpath])) if len(tests) == 1: relpath = self.build_obj._wrap_path_argument(tests[0]["manifest"]).relpath() print("%s found in manifest %s" % (self.full_test_name, relpath)) if tests[0].get("flavor"): print(" flavor: %s" % tests[0]["flavor"]) if tests[0].get("skip-if"): print(" skip-if: %s" % tests[0]["skip-if"]) if tests[0].get("fail-if"): print(" fail-if: %s" % tests[0]["fail-if"]) elif len(tests) == 0: print("%s not found in any test manifest!" % self.full_test_name) else: print("%s found in more than one manifest!" % self.full_test_name) # short_name is full_test_name without path self.short_name = None name_idx = self.full_test_name.rfind("/") if name_idx > 0: self.short_name = self.full_test_name[name_idx + 1 :] if self.short_name and self.short_name == self.test_name: self.short_name = None def get_platform(self, record): if "platform" in record["build"]: platform = record["build"]["platform"] else: platform = "-" platform_words = platform.split("-") types_label = "" # combine run and build types and eliminate duplicates run_types = [] if "run" in record and "type" in record["run"]: run_types = record["run"]["type"] run_types = run_types if isinstance(run_types, list) else [run_types] build_types = [] if "build" in record and "type" in record["build"]: build_types = record["build"]["type"] build_types = ( build_types if isinstance(build_types, list) else [build_types] ) run_types = list(set(run_types + build_types)) # '1proc' is used as a treeherder label but does not appear in run types if "e10s" not in run_types: run_types = run_types + ["1proc"] for run_type in run_types: # chunked is not interesting if run_type == "chunked": continue # e10s is the default: implied if run_type == "e10s": continue # sometimes a build/run type is already present in the build platform if run_type in platform_words: continue if types_label: types_label += "-" types_label += run_type return "%s/%s:" % (platform, types_label) def report_bugs(self): # Report open bugs matching test name search = self.full_test_name if self.test_name: search = "%s,%s" % (search, self.test_name) if self.short_name: search = "%s,%s" % (search, self.short_name) payload = {"quicksearch": search, "include_fields": "id,summary"} response = requests.get("https://bugzilla.mozilla.org/rest/bug", payload) response.raise_for_status() json_response = response.json() print("\nBugzilla quick search for '%s':" % search) if "bugs" in json_response: for bug in json_response["bugs"]: print("Bug %s: %s" % (bug["id"], bug["summary"])) else: print("No bugs found.") def report( self, test_names, start, end, show_info, show_bugs, ): self.start = start self.end = end self.show_info = show_info if not self.show_info and not show_bugs: # by default, show everything self.show_info = True show_bugs = True for test_name in test_names: print("===== %s =====" % test_name) self.test_name = test_name if len(self.test_name) < 6: print("'%s' is too short for a test name!" % self.test_name) continue self.set_test_name() if show_bugs: self.report_bugs() class TestInfoReport(TestInfo): """ Support 'mach test-info report': Report of test runs summarized by manifest and component. """ def __init__(self, verbose): TestInfo.__init__(self, verbose) self.threads = [] def update_report(self, by_component, result, path_mod): def update_item(item, label, value): # It is important to include any existing item value in case ActiveData # returns multiple records for the same test; that can happen if the report # sometimes maps more than one ActiveData record to the same path. new_value = item.get(label, 0) + value if type(new_value) == int: item[label] = new_value else: item[label] = float(round(new_value, 2)) # pylint: disable=W1633 if "test" in result and "tests" in by_component: test = result["test"] if path_mod: test = path_mod(test) for bc in by_component["tests"]: for item in by_component["tests"][bc]: if test == item["test"]: # pylint: disable=W1633 seconds = float(round(result.get("duration", 0), 2)) update_item(item, "total run time, seconds", seconds) update_item(item, "total runs", result.get("count", 0)) update_item(item, "skipped runs", result.get("skips", 0)) update_item(item, "failed runs", result.get("failures", 0)) return True return False def path_mod_reftest(self, path): # " == " -> "" path = path.split(" ")[0] # "?" -> "" path = path.split("?")[0] # "#" -> "" path = path.split("#")[0] return path def path_mod_jsreftest(self, path): # ";assert" -> "" path = path.split(";")[0] return path def path_mod_marionette(self, path): # " " -> "" path = path.split(" ")[0] # "part1\part2" -> "part1/part2" path = path.replace("\\", os.path.sep) return path def path_mod_wpt(self, path): if path[0] == os.path.sep: # "/" -> "" path = path[1:] # "" -> "testing/web-platform/tests/" path = os.path.join("testing", "web-platform", "tests", path) # "?" -> "" path = path.split("?")[0] return path def path_mod_jittest(self, path): # "part1\part2" -> "part1/part2" path = path.replace("\\", os.path.sep) # "" -> "js/src/jit-test/tests/" return os.path.join("js", "src", "jit-test", "tests", path) def path_mod_xpcshell(self, path): # .ini: -> "" path = path.split(".ini:")[-1] return path def description( self, components, flavor, subsuite, paths, show_manifests, show_tests, show_summary, show_annotations, filter_values, filter_keys, start_date, end_date, ): # provide a natural language description of the report options what = [] if show_manifests: what.append("test manifests") if show_tests: what.append("tests") if show_annotations: what.append("test manifest annotations") if show_summary and len(what) == 0: what.append("summary of tests only") if len(what) > 1: what[-1] = "and " + what[-1] what = ", ".join(what) d = "Test summary report for " + what if components: d += ", in specified components (%s)" % components else: d += ", in all components" if flavor: d += ", in specified flavor (%s)" % flavor if subsuite: d += ", in specified subsuite (%s)" % subsuite if paths: d += ", on specified paths (%s)" % paths if filter_values: d += ", containing '%s'" % filter_values if filter_keys: d += " in manifest keys '%s'" % filter_keys else: d += " in any part of manifest entry" d += ", including historical run-time data for the last " start = datetime.datetime.strptime(start_date, "%Y-%m-%d") end = datetime.datetime.strptime(end_date, "%Y-%m-%d") d += "%s days on trunk (autoland/m-c)" % ((end - start).days) d += " as of %s." % end_date return d # TODO: this is hacked for now and very limited def parse_test(self, summary): if summary.endswith("single tracking bug"): name_part = summary.split("|")[0] # remove 'single tracking bug' name_part.strip() return name_part.split()[-1] # get just the test name, not extra words return None def get_runcount_data(self, start, end): # TODO: use start/end properly runcounts = self.get_runcounts() runcounts = self.squash_runcounts(runcounts, days=30) return runcounts def get_testinfoall_index_url(self): import taskcluster queue = taskcluster.Queue() index = taskcluster.Index( { "rootUrl": "https://firefox-ci-tc.services.mozilla.com", } ) route = "gecko.v2.mozilla-central.latest.source.test-info-all" task_id = index.findTask(route)["taskId"] artifacts = queue.listLatestArtifacts(task_id)["artifacts"] url = "" for artifact in artifacts: if artifact["name"].endswith("test-run-info.json"): url = queue.buildUrl("getLatestArtifact", task_id, artifact["name"]) break return url def get_runcounts(self): testrundata = {} # get historical data from test-info job artifact; if missing get fresh try: url = self.get_testinfoall_index_url() r = requests.get(url, headers={"User-agent": "mach-test-info/1.0"}) r.raise_for_status() testrundata = r.json() except Exception: pass # fill in any holes we have endday = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( days=1 ) startday = endday - datetime.timedelta(days=30) while startday < endday: nextday = startday + datetime.timedelta(days=1) retries = 2 done = False if str(nextday) not in testrundata.keys(): while not done: url = "https://treeherder.mozilla.org/api/groupsummary/" url += "?startdate=%s&enddate=%s" % ( startday.date(), nextday.date(), ) try: r = requests.get( url, headers={"User-agent": "mach-test-info/1.0"} ) done = True except requests.exceptions.HTTPError: retries -= 1 if retries <= 0: r.raise_for_status() testrundata[str(nextday.date())] = r.json() startday = nextday return testrundata def squash_runcounts(self, runcounts, days=30): # squash all testrundata together into 1 big happy family for the last X days endday = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( days=1 ) oldest = endday - datetime.timedelta(days=days) testgroup_runinfo = defaultdict(lambda: defaultdict(int)) retVal = {} for datekey in runcounts.keys(): # strip out older days if datetime.date.fromisoformat(datekey) < oldest.date(): continue jtn = runcounts[datekey]["job_type_names"] for m in runcounts[datekey]["manifests"]: man_name = list(m.keys())[0] for job_type_id, result, classification, count in m[man_name]: # format: job_type_name, result, classification, count # find matching jtn, result, classification and increment 'count' job_name = jtn[job_type_id] key = (job_name, result, classification) testgroup_runinfo[man_name][key] += count for m in testgroup_runinfo: retVal[m] = [ list(x) + [testgroup_runinfo[m][x]] for x in testgroup_runinfo[m] ] return retVal def get_intermittent_failure_data(self, start, end): retVal = {} # get IFV bug list # i.e. https://th.m.o/api/failures/?startday=2022-06-22&endday=2022-06-29&tree=all url = ( "https://treeherder.mozilla.org/api/failures/?startday=%s&endday=%s&tree=trunk" % (start, end) ) r = requests.get(url, headers={"User-agent": "mach-test-info/1.0"}) if_data = r.json() buglist = [x["bug_id"] for x in if_data] # get bug data for summary, 800 bugs at a time # i.e. https://b.m.o/rest/bug?include_fields=id,product,component,summary&id=1,2,3... max_bugs = 800 bug_data = [] fields = ["id", "product", "component", "summary"] for bug_index in range(0, len(buglist), max_bugs): bugs = [str(x) for x in buglist[bug_index:max_bugs]] url = "https://bugzilla.mozilla.org/rest/bug?include_fields=%s&id=%s" % ( ",".join(fields), ",".join(bugs), ) r = requests.get(url, headers={"User-agent": "mach-test-info/1.0"}) data = r.json() if data and "bugs" in data.keys(): bug_data.extend(data["bugs"]) # for each summary, parse filename, store component # IF we find >1 bug with same testname, for now summarize as one for bug in bug_data: test_name = self.parse_test(bug["summary"]) if not test_name: continue c = int([x["bug_count"] for x in if_data if x["bug_id"] == bug["id"]][0]) if test_name not in retVal.keys(): retVal[test_name] = { "id": bug["id"], "count": 0, "product": bug["product"], "component": bug["component"], } retVal[test_name]["count"] += c if bug["product"] != retVal[test_name]["product"]: print( "ERROR | %s | mismatched bugzilla product, bugzilla (%s) != repo (%s)" % (bug["id"], bug["product"], retVal[test_name]["product"]) ) if bug["component"] != retVal[test_name]["component"]: print( "ERROR | %s | mismatched bugzilla component, bugzilla (%s) != repo (%s)" % (bug["id"], bug["component"], retVal[test_name]["component"]) ) return retVal def report( self, components, flavor, subsuite, paths, show_manifests, show_tests, show_summary, show_annotations, filter_values, filter_keys, show_components, output_file, start, end, show_testruns, ): def matches_filters(test): """ Return True if all of the requested filter_values are found in this test; if filter_keys are specified, restrict search to those test keys. """ for value in filter_values: value_found = False for key in test: if not filter_keys or key in filter_keys: if re.search(value, test[key]): value_found = True break if not value_found: return False return True start_time = datetime.datetime.now() # Ensure useful report by default if ( not show_manifests and not show_tests and not show_summary and not show_annotations ): show_manifests = True show_summary = True by_component = {} if components: components = components.split(",") if filter_keys: filter_keys = filter_keys.split(",") if filter_values: filter_values = filter_values.split(",") else: filter_values = [] display_keys = (filter_keys or []) + ["skip-if", "fail-if", "fails-if"] display_keys = set(display_keys) ifd = self.get_intermittent_failure_data(start, end) if show_testruns: runcount = self.get_runcount_data(start, end) print("Finding tests...") here = os.path.abspath(os.path.dirname(__file__)) resolver = TestResolver.from_environment( cwd=here, loader_cls=TestManifestLoader ) tests = list( resolver.resolve_tests(paths=paths, flavor=flavor, subsuite=subsuite) ) manifest_paths = set() for t in tests: if t.get("manifest", None): manifest_path = t["manifest"] if t.get("ancestor_manifest", None): manifest_path = "%s:%s" % (t["ancestor_manifest"], t["manifest"]) manifest_paths.add(manifest_path) manifest_count = len(manifest_paths) print( "Resolver found {} tests, {} manifests".format(len(tests), manifest_count) ) if show_manifests: topsrcdir = self.build_obj.topsrcdir by_component["manifests"] = {} manifest_paths = list(manifest_paths) manifest_paths.sort() relpaths = [] for manifest_path in manifest_paths: relpath = mozpath.relpath(manifest_path, topsrcdir) if mozpath.commonprefix((manifest_path, topsrcdir)) != topsrcdir: continue relpaths.append(relpath) reader = self.build_obj.mozbuild_reader(config_mode="empty") files_info = reader.files_info(relpaths) for manifest_path in manifest_paths: relpath = mozpath.relpath(manifest_path, topsrcdir) if mozpath.commonprefix((manifest_path, topsrcdir)) != topsrcdir: continue manifest_info = None if relpath in files_info: bug_component = files_info[relpath].get("BUG_COMPONENT") if bug_component: key = "{}::{}".format( bug_component.product, bug_component.component ) else: key = "" if (not components) or (key in components): manifest_info = {"manifest": relpath, "tests": 0, "skipped": 0} rkey = key if show_components else "all" if rkey in by_component["manifests"]: by_component["manifests"][rkey].append(manifest_info) else: by_component["manifests"][rkey] = [manifest_info] if manifest_info: for t in tests: if t["manifest"] == manifest_path: manifest_info["tests"] += 1 if t.get("skip-if"): manifest_info["skipped"] += 1 for key in by_component["manifests"]: by_component["manifests"][key].sort(key=lambda k: k["manifest"]) if show_tests: by_component["tests"] = {} if show_tests or show_summary or show_annotations: test_count = 0 failed_count = 0 skipped_count = 0 annotation_count = 0 condition_count = 0 component_set = set() relpaths = [] conditions = {} known_unconditional_annotations = ["skip", "fail", "asserts", "random"] known_conditional_annotations = [ "skip-if", "fail-if", "run-if", "fails-if", "fuzzy-if", "random-if", "asserts-if", ] for t in tests: relpath = t.get("srcdir_relpath") relpaths.append(relpath) reader = self.build_obj.mozbuild_reader(config_mode="empty") files_info = reader.files_info(relpaths) for t in tests: if not matches_filters(t): continue if "referenced-test" in t: # Avoid double-counting reftests: disregard reference file entries continue if show_annotations: for key in t: if key in known_unconditional_annotations: annotation_count += 1 if key in known_conditional_annotations: annotation_count += 1 # Here 'key' is a manifest annotation type like 'skip-if' and t[key] # is the associated condition. For example, the manifestparser # manifest annotation, "skip-if = os == 'win'", is expected to be # encoded as t['skip-if'] = "os == 'win'". # To allow for reftest manifests, t[key] may have multiple entries # separated by ';', each corresponding to a condition for that test # and annotation type. For example, # "skip-if(Android&&webrender) skip-if(OSX)", would be # encoded as t['skip-if'] = "Android&&webrender;OSX". annotation_conditions = t[key].split(";") # if key has \n in it, we need to strip it. for manifestparser format # 1) from the beginning of the line # 2) different conditions if in the middle of the line annotation_conditions = [ x.strip("\n") for x in annotation_conditions ] temp = [] for condition in annotation_conditions: temp.extend(condition.split("\n")) annotation_conditions = temp for condition in annotation_conditions: condition_count += 1 # Trim reftest fuzzy-if ranges: everything after the first comma # eg. "Android,0-2,1-3" -> "Android" condition = condition.split(",")[0] if condition not in conditions: conditions[condition] = 0 conditions[condition] += 1 test_count += 1 relpath = t.get("srcdir_relpath") if relpath in files_info: bug_component = files_info[relpath].get("BUG_COMPONENT") if bug_component: key = "{}::{}".format( bug_component.product, bug_component.component ) else: key = "" if (not components) or (key in components): component_set.add(key) test_info = {"test": relpath} for test_key in display_keys: value = t.get(test_key) if value: test_info[test_key] = value if t.get("fail-if"): failed_count += 1 if t.get("fails-if"): failed_count += 1 if t.get("skip-if"): skipped_count += 1 if "manifest_relpath" in t and "manifest" in t: if "web-platform" in t["manifest_relpath"]: test_info["manifest"] = [t["manifest"]] else: test_info["manifest"] = [t["manifest_relpath"]] # handle included manifests as ancestor:child if t.get("ancestor_manifest", None): test_info["manifest"] = [ "%s:%s" % (t["ancestor_manifest"], test_info["manifest"][0]) ] # add in intermittent failure data if ifd.get(relpath): if_data = ifd.get(relpath) test_info["failure_count"] = if_data["count"] if show_testruns: total_runs = 0 for m in test_info["manifest"]: total_runs += sum([x[3] for x in runcount[m]]) if total_runs > 0: test_info["total_runs"] = total_runs if show_tests: rkey = key if show_components else "all" if rkey in by_component["tests"]: # Avoid duplicates: Some test paths have multiple TestResolver # entries, as when a test is included by multiple manifests. found = False for ctest in by_component["tests"][rkey]: if ctest["test"] == test_info["test"]: found = True break if not found: by_component["tests"][rkey].append(test_info) else: for ti in by_component["tests"][rkey]: if ti["test"] == test_info["test"]: if ( test_info["manifest"][0] not in ti["manifest"] ): ti_manifest = test_info["manifest"] if test_info.get( "ancestor_manifest", None ): ti_manifest = "%s:%s" % ( test_info["ancestor_manifest"], ti_manifest, ) ti["manifest"].extend(ti_manifest) else: by_component["tests"][rkey] = [test_info] if show_tests: for key in by_component["tests"]: by_component["tests"][key].sort(key=lambda k: k["test"]) by_component["description"] = self.description( components, flavor, subsuite, paths, show_manifests, show_tests, show_summary, show_annotations, filter_values, filter_keys, start, end, ) if show_summary: by_component["summary"] = {} by_component["summary"]["components"] = len(component_set) by_component["summary"]["manifests"] = manifest_count by_component["summary"]["tests"] = test_count by_component["summary"]["failed tests"] = failed_count by_component["summary"]["skipped tests"] = skipped_count if show_annotations: by_component["annotations"] = {} by_component["annotations"]["total annotations"] = annotation_count by_component["annotations"]["total conditions"] = condition_count by_component["annotations"]["unique conditions"] = len(conditions) by_component["annotations"]["conditions"] = conditions self.write_report(by_component, output_file) end_time = datetime.datetime.now() self.log_verbose( "%d seconds total to generate report" % (end_time - start_time).total_seconds() ) def write_report(self, by_component, output_file): json_report = json.dumps(by_component, indent=2, sort_keys=True) if output_file: output_file = os.path.abspath(output_file) output_dir = os.path.dirname(output_file) if not os.path.isdir(output_dir): os.makedirs(output_dir) with open(output_file, "w") as f: f.write(json_report) else: print(json_report) def report_diff(self, before, after, output_file): """ Support for 'mach test-info report-diff'. """ def get_file(path_or_url): if urlparse.urlparse(path_or_url).scheme: response = requests.get(path_or_url) response.raise_for_status() return json.loads(response.text) with open(path_or_url) as f: return json.load(f) report1 = get_file(before) report2 = get_file(after) by_component = {"tests": {}, "summary": {}} self.diff_summaries(by_component, report1["summary"], report2["summary"]) self.diff_all_components(by_component, report1["tests"], report2["tests"]) self.write_report(by_component, output_file) def diff_summaries(self, by_component, summary1, summary2): """ Update by_component with comparison of summaries. """ all_keys = set(summary1.keys()) | set(summary2.keys()) for key in all_keys: delta = summary2.get(key, 0) - summary1.get(key, 0) by_component["summary"]["%s delta" % key] = delta def diff_all_components(self, by_component, tests1, tests2): """ Update by_component with any added/deleted tests, for all components. """ self.added_count = 0 self.deleted_count = 0 for component in tests1: component1 = tests1[component] component2 = [] if component not in tests2 else tests2[component] self.diff_component(by_component, component, component1, component2) for component in tests2: if component not in tests1: component2 = tests2[component] self.diff_component(by_component, component, [], component2) by_component["summary"]["added tests"] = self.added_count by_component["summary"]["deleted tests"] = self.deleted_count def diff_component(self, by_component, component, component1, component2): """ Update by_component[component] with any added/deleted tests for the named component. "added": tests found in component2 but missing from component1. "deleted": tests found in component1 but missing from component2. """ tests1 = set([t["test"] for t in component1]) tests2 = set([t["test"] for t in component2]) deleted = tests1 - tests2 added = tests2 - tests1 if deleted or added: by_component["tests"][component] = {} if deleted: by_component["tests"][component]["deleted"] = sorted(list(deleted)) if added: by_component["tests"][component]["added"] = sorted(list(added)) self.added_count += len(added) self.deleted_count += len(deleted) common = len(tests1.intersection(tests2)) self.log_verbose( "%s: %d deleted, %d added, %d common" % (component, len(deleted), len(added), common) )