summaryrefslogtreecommitdiffstats
path: root/testing/mozharness/mozharness/mozilla/testing/per_test_base.py
diff options
context:
space:
mode:
Diffstat (limited to 'testing/mozharness/mozharness/mozilla/testing/per_test_base.py')
-rw-r--r--testing/mozharness/mozharness/mozilla/testing/per_test_base.py542
1 files changed, 542 insertions, 0 deletions
diff --git a/testing/mozharness/mozharness/mozilla/testing/per_test_base.py b/testing/mozharness/mozharness/mozilla/testing/per_test_base.py
new file mode 100644
index 0000000000..c171652e67
--- /dev/null
+++ b/testing/mozharness/mozharness/mozilla/testing/per_test_base.py
@@ -0,0 +1,542 @@
+#!/usr/bin/env python
+# ***** BEGIN LICENSE BLOCK *****
+# 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/.
+# ***** END LICENSE BLOCK *****
+
+import itertools
+import json
+import math
+import os
+import posixpath
+import sys
+
+import mozinfo
+
+
+class SingleTestMixin(object):
+ """Utility functions for per-test testing like test verification and per-test coverage."""
+
+ def __init__(self, **kwargs):
+ super(SingleTestMixin, self).__init__(**kwargs)
+
+ self.suites = {}
+ self.tests_downloaded = False
+ self.reftest_test_dir = None
+ self.jsreftest_test_dir = None
+ # Map from full test path on the test machine to a relative path in the source checkout.
+ # Use self._map_test_path_to_source(test_machine_path, source_path) to add a mapping.
+ self.test_src_path = {}
+ self.per_test_log_index = 1
+
+ def _map_test_path_to_source(self, test_machine_path, source_path):
+ test_machine_path = test_machine_path.replace(os.sep, posixpath.sep)
+ source_path = source_path.replace(os.sep, posixpath.sep)
+ self.test_src_path[test_machine_path] = source_path
+
+ def _is_gpu_suite(self, suite):
+ if suite and (suite == "gpu" or suite.startswith("webgl")):
+ return True
+ return False
+
+ def _find_misc_tests(self, dirs, changed_files, gpu=False):
+ manifests = [
+ (
+ os.path.join(dirs["abs_mochitest_dir"], "tests", "mochitest.toml"),
+ "mochitest-plain",
+ ),
+ (
+ os.path.join(dirs["abs_mochitest_dir"], "chrome", "chrome.toml"),
+ "mochitest-chrome",
+ ),
+ (
+ os.path.join(
+ dirs["abs_mochitest_dir"], "browser", "browser-chrome.toml"
+ ),
+ "mochitest-browser-chrome",
+ ),
+ (
+ os.path.join(dirs["abs_mochitest_dir"], "a11y", "a11y.toml"),
+ "mochitest-a11y",
+ ),
+ (
+ os.path.join(dirs["abs_xpcshell_dir"], "tests", "xpcshell.toml"),
+ "xpcshell",
+ ),
+ ]
+ is_fission = "fission.autostart=true" in self.config.get("extra_prefs", [])
+ tests_by_path = {}
+ all_disabled = []
+ # HACK: import here so we don't need import for rest of class
+ from manifestparser import TestManifest
+
+ for path, suite in manifests:
+ if os.path.exists(path):
+ man = TestManifest([path], strict=False)
+ active = man.active_tests(
+ exists=False, disabled=True, filters=[], **mozinfo.info
+ )
+ # Remove disabled tests. Also, remove tests with the same path as
+ # disabled tests, even if they are not disabled, since per-test mode
+ # specifies tests by path (it cannot distinguish between two or more
+ # tests with the same path specified in multiple manifests).
+ disabled = [t["relpath"] for t in active if "disabled" in t]
+ all_disabled += disabled
+ new_by_path = {
+ t["relpath"]: (suite, t.get("subsuite"), None)
+ for t in active
+ if "disabled" not in t and t["relpath"] not in disabled
+ }
+ tests_by_path.update(new_by_path)
+ self.info(
+ "Per-test run updated with manifest %s (%d active, %d skipped)"
+ % (path, len(new_by_path), len(disabled))
+ )
+
+ ref_manifests = [
+ (
+ os.path.join(
+ dirs["abs_reftest_dir"],
+ "tests",
+ "layout",
+ "reftests",
+ "reftest.list",
+ ),
+ "reftest",
+ "gpu",
+ ), # gpu
+ (
+ os.path.join(
+ dirs["abs_reftest_dir"],
+ "tests",
+ "testing",
+ "crashtest",
+ "crashtests.list",
+ ),
+ "crashtest",
+ None,
+ ),
+ ]
+ sys.path.append(dirs["abs_reftest_dir"])
+ import manifest
+
+ self.reftest_test_dir = os.path.join(dirs["abs_reftest_dir"], "tests")
+ for path, suite, subsuite in ref_manifests:
+ if os.path.exists(path):
+ man = manifest.ReftestManifest()
+ man.load(path)
+ for t in man.tests:
+ relpath = os.path.relpath(t["path"], self.reftest_test_dir)
+ referenced = (
+ t["referenced-test"] if "referenced-test" in t else None
+ )
+ tests_by_path[relpath] = (suite, subsuite, referenced)
+ self._map_test_path_to_source(t["path"], relpath)
+ self.info(
+ "Per-test run updated with manifest %s (%d tests)"
+ % (path, len(man.tests))
+ )
+
+ suite = "jsreftest"
+ self.jsreftest_test_dir = os.path.join(
+ dirs["abs_test_install_dir"], "jsreftest", "tests"
+ )
+ path = os.path.join(self.jsreftest_test_dir, "jstests.list")
+ if os.path.exists(path):
+ man = manifest.ReftestManifest()
+ man.load(path)
+ for t in man.files:
+ # expect manifest test to look like:
+ # ".../tests/jsreftest/tests/jsreftest.html?test=test262/.../some_test.js"
+ # while the test is in mercurial at:
+ # js/src/tests/test262/.../some_test.js
+ epos = t.find("=")
+ if epos > 0:
+ relpath = t[epos + 1 :]
+ test_path = os.path.join(self.jsreftest_test_dir, relpath)
+ relpath = os.path.join("js", "src", "tests", relpath)
+ self._map_test_path_to_source(test_path, relpath)
+ tests_by_path.update({relpath: (suite, None, None)})
+ else:
+ self.warning("unexpected jsreftest test format: %s" % str(t))
+ self.info(
+ "Per-test run updated with manifest %s (%d tests)"
+ % (path, len(man.files))
+ )
+
+ # for each changed file, determine if it is a test file, and what suite it is in
+ for file in changed_files:
+ # manifest paths use os.sep (like backslash on Windows) but
+ # automation-relevance uses posixpath.sep
+ file = file.replace(posixpath.sep, os.sep)
+ entry = tests_by_path.get(file)
+ if not entry:
+ if file in all_disabled:
+ self.info("'%s' has been skipped on this platform." % file)
+ if os.environ.get("MOZHARNESS_TEST_PATHS", None) is not None:
+ self.info("Per-test run could not find requested test '%s'" % file)
+ continue
+
+ if gpu and not self._is_gpu_suite(entry[1]):
+ self.info(
+ "Per-test run (gpu) discarded non-gpu test %s (%s)"
+ % (file, entry[1])
+ )
+ continue
+ elif not gpu and self._is_gpu_suite(entry[1]):
+ self.info(
+ "Per-test run (non-gpu) discarded gpu test %s (%s)"
+ % (file, entry[1])
+ )
+ continue
+
+ if is_fission and (
+ (entry[0] == "mochitest-a11y") or (entry[0] == "mochitest-chrome")
+ ):
+ self.info(
+ "Per-test run (fission) discarded non-e10s test %s (%s)"
+ % (file, entry[0])
+ )
+ continue
+
+ if entry[2] is not None and "about:" not in entry[2]:
+ # Test name substitution, for reftest reference file handling:
+ # - if both test and reference modified, run the test file
+ # - if only reference modified, run the test file
+ test_file = os.path.join(
+ os.path.dirname(file), os.path.basename(entry[2])
+ )
+ self.info("Per-test run substituting %s for %s" % (test_file, file))
+ file = test_file
+
+ self.info("Per-test run found test %s (%s/%s)" % (file, entry[0], entry[1]))
+ subsuite_mapping = {
+ # Map (<suite>, <subsuite>): <full-suite>
+ # <suite> is associated with a manifest, explicitly in code above
+ # <subsuite> comes from "subsuite" tags in some manifest entries
+ # <full-suite> is a unique id for the suite, matching desktop mozharness configs
+ (
+ "mochitest-browser-chrome",
+ "a11y",
+ None,
+ ): "mochitest-browser-a11y",
+ (
+ "mochitest-browser-chrome",
+ "media-bc",
+ None,
+ ): "mochitest-browser-media",
+ (
+ "mochitest-browser-chrome",
+ "devtools",
+ None,
+ ): "mochitest-devtools-chrome",
+ ("mochitest-browser-chrome", "remote", None): "mochitest-remote",
+ (
+ "mochitest-browser-chrome",
+ "screenshots",
+ None,
+ ): "mochitest-browser-screenshots", # noqa
+ ("mochitest-plain", "media", None): "mochitest-media",
+ # below should be on test-verify-gpu job
+ ("mochitest-chrome", "gpu", None): "mochitest-chrome-gpu",
+ ("mochitest-plain", "gpu", None): "mochitest-plain-gpu",
+ ("mochitest-plain", "webgl1-core", None): "mochitest-webgl1-core",
+ ("mochitest-plain", "webgl1-ext", None): "mochitest-webgl1-ext",
+ ("mochitest-plain", "webgl2-core", None): "mochitest-webgl2-core",
+ ("mochitest-plain", "webgl2-ext", None): "mochitest-webgl2-ext",
+ ("mochitest-plain", "webgl2-deqp", None): "mochitest-webgl2-deqp",
+ ("mochitest-plain", "webgpu", None): "mochitest-webgpu",
+ }
+ if entry in subsuite_mapping:
+ suite = subsuite_mapping[entry]
+ else:
+ suite = entry[0]
+ suite_files = self.suites.get(suite)
+ if not suite_files:
+ suite_files = []
+ if file not in suite_files:
+ suite_files.append(file)
+ self.suites[suite] = suite_files
+
+ def _find_wpt_tests(self, dirs, changed_files):
+ # Setup sys.path to include all the dependencies required to import
+ # the web-platform-tests manifest parser. web-platform-tests provides
+ # the localpaths.py to do the path manipulation, which we load,
+ # providing the __file__ variable so it can resolve the relative
+ # paths correctly.
+ paths_file = os.path.join(
+ dirs["abs_wpttest_dir"], "tests", "tools", "localpaths.py"
+ )
+ with open(paths_file, "r") as f:
+ exec(f.read(), {"__file__": paths_file})
+ import manifest as wptmanifest
+
+ tests_root = os.path.join(dirs["abs_wpttest_dir"], "tests")
+
+ for extra in ("", "mozilla"):
+ base_path = os.path.join(dirs["abs_wpttest_dir"], extra)
+ man_path = os.path.join(base_path, "meta", "MANIFEST.json")
+ man = wptmanifest.manifest.load(tests_root, man_path)
+ self.info("Per-test run updated with manifest %s" % man_path)
+
+ repo_tests_path = os.path.join("testing", "web-platform", extra, "tests")
+ tests_path = os.path.join("tests", "web-platform", extra, "tests")
+ for type, path, test in man:
+ if type not in ["testharness", "reftest", "wdspec"]:
+ continue
+ repo_path = os.path.join(repo_tests_path, path)
+ # manifest paths use os.sep (like backslash on Windows) but
+ # automation-relevance uses posixpath.sep
+ repo_path = repo_path.replace(os.sep, posixpath.sep)
+ if repo_path in changed_files:
+ self.info(
+ "Per-test run found web-platform test '%s', type %s"
+ % (path, type)
+ )
+ suite_files = self.suites.get(type)
+ if not suite_files:
+ suite_files = []
+ test_path = os.path.join(tests_path, path)
+ suite_files.append(test_path)
+ self.suites[type] = suite_files
+ self._map_test_path_to_source(test_path, repo_path)
+ changed_files.remove(repo_path)
+
+ if os.environ.get("MOZHARNESS_TEST_PATHS", None) is not None:
+ for file in changed_files:
+ self.info(
+ "Per-test run could not find requested web-platform test '%s'"
+ % file
+ )
+
+ def find_modified_tests(self):
+ """
+ For each file modified on this push, determine if the modified file
+ is a test, by searching test manifests. Populate self.suites
+ with test files, organized by suite.
+
+ This depends on test manifests, so can only run after test zips have
+ been downloaded and extracted.
+ """
+ repository = os.environ.get("GECKO_HEAD_REPOSITORY")
+ revision = os.environ.get("GECKO_HEAD_REV")
+ if not repository or not revision:
+ self.warning("unable to run tests in per-test mode: no repo or revision!")
+ self.suites = {}
+ self.tests_downloaded = True
+ return
+
+ def get_automationrelevance():
+ response = self.load_json_url(url)
+ return response
+
+ dirs = self.query_abs_dirs()
+ mozinfo.find_and_update_from_json(dirs["abs_test_install_dir"])
+ e10s = self.config.get("e10s", False)
+ mozinfo.update({"e10s": e10s})
+ is_fission = "fission.autostart=true" in self.config.get("extra_prefs", [])
+ mozinfo.update({"fission": is_fission})
+ headless = self.config.get("headless", False)
+ mozinfo.update({"headless": headless})
+ if mozinfo.info["buildapp"] == "mobile/android":
+ # extra android mozinfo normally comes from device queries, but this
+ # code may run before the device is ready, so rely on configuration
+ mozinfo.update(
+ {"android_version": str(self.config.get("android_version", 24))}
+ )
+ mozinfo.update({"is_emulator": self.config.get("is_emulator", True)})
+ mozinfo.update({"verify": True})
+ self.info("Per-test run using mozinfo: %s" % str(mozinfo.info))
+
+ # determine which files were changed on this push
+ changed_files = set()
+ url = "%s/json-automationrelevance/%s" % (repository.rstrip("/"), revision)
+ contents = self.retry(get_automationrelevance, attempts=2, sleeptime=10)
+ for c in contents["changesets"]:
+ self.info(
+ " {cset} {desc}".format(
+ cset=c["node"][0:12],
+ desc=c["desc"].splitlines()[0].encode("ascii", "ignore"),
+ )
+ )
+ changed_files |= set(c["files"])
+ changed_files = list(changed_files)
+
+ # check specified test paths, as from 'mach try ... <path>'
+ if os.environ.get("MOZHARNESS_TEST_PATHS", None) is not None:
+ suite_to_paths = json.loads(os.environ["MOZHARNESS_TEST_PATHS"])
+ specified_paths = itertools.chain.from_iterable(suite_to_paths.values())
+ specified_paths = list(specified_paths)
+ # filter the list of changed files to those found under the
+ # specified path(s)
+ changed_and_specified = set()
+ for changed in changed_files:
+ for specified in specified_paths:
+ if changed.startswith(specified):
+ changed_and_specified.add(changed)
+ break
+ if changed_and_specified:
+ changed_files = changed_and_specified
+ else:
+ # if specified paths do not match changed files, assume the
+ # specified paths are explicitly requested tests
+ changed_files = set()
+ changed_files.update(specified_paths)
+ self.info("Per-test run found explicit request in MOZHARNESS_TEST_PATHS:")
+ self.info(str(changed_files))
+
+ if self.config.get("per_test_category") == "web-platform":
+ self._find_wpt_tests(dirs, changed_files)
+ elif self.config.get("gpu_required", False) is not False:
+ self._find_misc_tests(dirs, changed_files, gpu=True)
+ else:
+ self._find_misc_tests(dirs, changed_files)
+
+ # per test mode run specific tests from any given test suite
+ # _find_*_tests organizes tests to run into suites so we can
+ # run each suite at a time
+
+ # chunk files
+ total_tests = sum([len(self.suites[x]) for x in self.suites])
+
+ if total_tests == 0:
+ self.warning("No tests to verify.")
+ self.suites = {}
+ self.tests_downloaded = True
+ return
+
+ files_per_chunk = total_tests / float(self.config.get("total_chunks", 1))
+ files_per_chunk = int(math.ceil(files_per_chunk))
+
+ chunk_number = int(self.config.get("this_chunk", 1))
+ suites = {}
+ start = (chunk_number - 1) * files_per_chunk
+ end = chunk_number * files_per_chunk
+ current = -1
+ for suite in self.suites:
+ for test in self.suites[suite]:
+ current += 1
+ if current >= start and current < end:
+ if suite not in suites:
+ suites[suite] = []
+ suites[suite].append(test)
+ if current >= end:
+ break
+
+ self.suites = suites
+ self.tests_downloaded = True
+
+ def query_args(self, suite):
+ """
+ For the specified suite, return an array of command line arguments to
+ be passed to test harnesses when running in per-test mode.
+
+ Each array element is an array of command line arguments for a modified
+ test in the suite.
+ """
+ # not in verify or per-test coverage mode: run once, with no additional args
+ if not self.per_test_coverage and not self.verify_enabled:
+ return [[]]
+
+ files = []
+ jsreftest_extra_dir = os.path.join("js", "src", "tests")
+ # For some suites, the test path needs to be updated before passing to
+ # the test harness.
+ for file in self.suites.get(suite):
+ if self.config.get("per_test_category") != "web-platform" and suite in [
+ "reftest",
+ "crashtest",
+ ]:
+ file = os.path.join(self.reftest_test_dir, file)
+ elif (
+ self.config.get("per_test_category") != "web-platform"
+ and suite == "jsreftest"
+ ):
+ file = os.path.relpath(file, jsreftest_extra_dir)
+ file = os.path.join(self.jsreftest_test_dir, file)
+
+ if file is None:
+ continue
+
+ file = file.replace(os.sep, posixpath.sep)
+ files.append(file)
+
+ self.info("Per-test file(s) for '%s': %s" % (suite, files))
+
+ args = []
+ for file in files:
+ cur = []
+
+ cur.extend(self.coverage_args)
+ cur.extend(self.verify_args)
+
+ cur.append(file)
+ args.append(cur)
+
+ return args
+
+ def query_per_test_category_suites(self, category, all_suites):
+ """
+ In per-test mode, determine which suites are active, for the given
+ suite category.
+ """
+ suites = None
+ if self.verify_enabled or self.per_test_coverage:
+ if self.config.get("per_test_category") == "web-platform":
+ suites = list(self.suites)
+ self.info("Per-test suites: %s" % suites)
+ elif all_suites and self.tests_downloaded:
+ suites = dict(
+ (key, all_suites.get(key))
+ for key in self.suites
+ if key in all_suites.keys()
+ )
+ self.info("Per-test suites: %s" % suites)
+ else:
+ # Until test zips are downloaded, manifests are not available,
+ # so it is not possible to determine which suites are active/
+ # required for per-test mode; assume all suites from supported
+ # suite categories are required.
+ if category in ["mochitest", "xpcshell", "reftest"]:
+ suites = all_suites
+ return suites
+
+ def log_per_test_status(self, test_name, tbpl_status, log_level):
+ """
+ Log status of a single test. This will display in the
+ Job Details pane in treeherder - a convenient summary of per-test mode.
+ Special test name formatting is needed because treeherder truncates
+ lines that are too long, and may remove duplicates after truncation.
+ """
+ max_test_name_len = 40
+ if len(test_name) > max_test_name_len:
+ head = test_name
+ new = ""
+ previous = None
+ max_test_name_len = max_test_name_len - len(".../")
+ while len(new) < max_test_name_len:
+ head, tail = os.path.split(head)
+ previous = new
+ new = os.path.join(tail, new)
+ test_name = os.path.join("...", previous or new)
+ test_name = test_name.rstrip(os.path.sep)
+ self.log(
+ "TinderboxPrint: Per-test run of %s<br/>: %s" % (test_name, tbpl_status),
+ level=log_level,
+ )
+
+ def get_indexed_logs(self, dir, test_suite):
+ """
+ Per-test tasks need distinct file names for the raw and errorsummary logs
+ on each run.
+ """
+ index = ""
+ if self.verify_enabled or self.per_test_coverage:
+ index = "-test%d" % self.per_test_log_index
+ self.per_test_log_index += 1
+ raw_log_file = os.path.join(dir, "%s%s_raw.log" % (test_suite, index))
+ error_summary_file = os.path.join(
+ dir, "%s%s_errorsummary.log" % (test_suite, index)
+ )
+ return raw_log_file, error_summary_file