Bug 1293259 - Stop reading/processing test manifests as part of the build draft
authorAndrew Halberstadt <ahalberstadt@mozilla.com>
Tue, 15 Nov 2016 09:49:07 -0500
changeset 439177 a403164e600ba64f5918b2ffc4d1729b21e9c517
parent 439176 dbbdc837d5bb97d73f346df3010929f22e95d52f
child 537092 383640441f804b844d03323be68a72c0d25c21c5
push id35923
push userahalberstadt@mozilla.com
push dateTue, 15 Nov 2016 15:06:20 +0000
bugs1293259
milestone53.0a1
Bug 1293259 - Stop reading/processing test manifests as part of the build This is a WIP. It has only been tested with |mach mochitest| and even then there are known reasons for tests to fail. It also needs to be tested to make sure the same set of tests are being resolved. It seems to save a modest ~6 seconds off build times. This change attempts to speed up the build system by not processing test manifest files at build time. You'll notice large swathes of build backend/frontend code deleted. This means no "all-tests.pkl" files. Instead, the manifest.ini trees will be walked and processed at test runtime. This patch has only been tested with |mach mochitest|. Processing of reftest, wpt and python unittests has been removed for now. We'll either need to create manifest based resolvers for each of those, or continue using the "all-tests.pkl" method. My preference is for the former. Another problem with this approach is that the concept of "deferred_installs" is no longer viable. Because we aren't processing all the manifests at build time, we have no way of finding the original install belonging to a deferred_install. This patch instead copies deferred_installs from the path in topsrcdir. This works for most cases, but in some cases this will result in the deferred_install being copied to the wrong destination which means mochitests will fail due to missing support-files. We'll either need to update all the manifests, or figure out something more clever to handle this case. MozReview-Commit-ID: DKyhwQjamUA
python/moz.build
python/mozbuild/mozbuild/backend/common.py
python/mozbuild/mozbuild/backend/recursivemake.py
python/mozbuild/mozbuild/controller/building.py
python/mozbuild/mozbuild/frontend/context.py
python/mozbuild/mozbuild/frontend/data.py
python/mozbuild/mozbuild/frontend/emitter.py
python/mozbuild/mozbuild/test/test_testing.py
python/mozbuild/mozbuild/testing.py
python/mozbuild/mozpack/resolve.py
python/mozbuild/mozpack/test/test_resolve.py
testing/mochitest/mach_commands.py
--- a/python/moz.build
+++ b/python/moz.build
@@ -75,14 +75,15 @@ PYTHON_UNIT_TESTS += [
     'mozbuild/mozpack/test/test_files.py',
     'mozbuild/mozpack/test/test_manifests.py',
     'mozbuild/mozpack/test/test_mozjar.py',
     'mozbuild/mozpack/test/test_packager.py',
     'mozbuild/mozpack/test/test_packager_formats.py',
     'mozbuild/mozpack/test/test_packager_l10n.py',
     'mozbuild/mozpack/test/test_packager_unpack.py',
     'mozbuild/mozpack/test/test_path.py',
+    'mozbuild/mozpack/test/test_resolve.py',
     'mozbuild/mozpack/test/test_unify.py',
     'mozlint/test/test_formatters.py',
     'mozlint/test/test_parser.py',
     'mozlint/test/test_roller.py',
     'mozlint/test/test_types.py',
 ]
--- a/python/mozbuild/mozbuild/backend/common.py
+++ b/python/mozbuild/mozbuild/backend/common.py
@@ -161,87 +161,38 @@ class WebIDLCollection(object):
 
     def generated_events_basenames(self):
         return [os.path.basename(s) for s in self.generated_events_sources]
 
     def generated_events_stems(self):
         return [os.path.splitext(b)[0] for b in self.generated_events_basenames()]
 
 
-class TestManager(object):
-    """Helps hold state related to tests."""
-
-    def __init__(self, config):
-        self.config = config
-        self.topsrcdir = mozpath.normpath(config.topsrcdir)
-
-        self.tests_by_path = defaultdict(list)
-        self.installs_by_path = defaultdict(list)
-        self.deferred_installs = set()
-        self.manifest_defaults = {}
-
-    def add(self, t, flavor, topsrcdir):
-        t = dict(t)
-        t['flavor'] = flavor
-
-        path = mozpath.normpath(t['path'])
-        assert mozpath.basedir(path, [topsrcdir])
-
-        key = path[len(topsrcdir)+1:]
-        t['file_relpath'] = key
-        t['dir_relpath'] = mozpath.dirname(key)
-
-        self.tests_by_path[key].append(t)
-
-    def add_defaults(self, manifest):
-        if not hasattr(manifest, 'manifest_defaults'):
-            return
-        for sub_manifest, defaults in manifest.manifest_defaults.items():
-            self.manifest_defaults[sub_manifest] = defaults
-
-    def add_installs(self, obj, topsrcdir):
-        for src, (dest, _) in obj.installs.iteritems():
-            key = src[len(topsrcdir)+1:]
-            self.installs_by_path[key].append((src, dest))
-        for src, pat, dest in obj.pattern_installs:
-            key = mozpath.join(src[len(topsrcdir)+1:], pat)
-            self.installs_by_path[key].append((src, pat, dest))
-        for path in obj.deferred_installs:
-            self.deferred_installs.add(path[2:])
-
-
 class BinariesCollection(object):
     """Tracks state of binaries produced by the build."""
 
     def __init__(self):
         self.shared_libraries = []
         self.programs = []
 
 
 class CommonBackend(BuildBackend):
     """Holds logic common to all build backends."""
 
     def _init(self):
         self._idl_manager = XPIDLManager(self.environment)
-        self._test_manager = TestManager(self.environment)
         self._webidls = WebIDLCollection()
         self._binaries = BinariesCollection()
         self._configs = set()
         self._ipdl_sources = set()
 
     def consume_object(self, obj):
         self._configs.add(obj.config)
 
-        if isinstance(obj, TestManifest):
-            for test in obj.tests:
-                self._test_manager.add(test, obj.flavor, obj.topsrcdir)
-            self._test_manager.add_defaults(obj.manifest)
-            self._test_manager.add_installs(obj, obj.topsrcdir)
-
-        elif isinstance(obj, XPIDLFile):
+        if isinstance(obj, XPIDLFile):
             # TODO bug 1240134 tracks not processing XPIDL files during
             # artifact builds.
             self._idl_manager.register_idl(obj)
 
         elif isinstance(obj, ConfigFileSubstitution):
             # Do not handle ConfigFileSubstitution for Makefiles. Leave that
             # to other
             if mozpath.basename(obj.output_path) == 'Makefile':
@@ -364,32 +315,18 @@ class CommonBackend(BuildBackend):
                                                           files_per_unified_file=16))
 
         self._write_unified_files(unified_source_mapping, ipdl_dir, poison_windows_h=False)
         self._handle_ipdl_sources(ipdl_dir, sorted_ipdl_sources, unified_source_mapping)
 
         for config in self._configs:
             self.backend_input_files.add(config.source)
 
-        # Write out a machine-readable file describing every test.
+        # Write out a machine-readable file describing binaries.
         topobjdir = self.environment.topobjdir
-        with self._write_file(mozpath.join(topobjdir, 'all-tests.pkl'), mode='rb') as fh:
-            pickle.dump(dict(self._test_manager.tests_by_path), fh, protocol=2)
-
-        with self._write_file(mozpath.join(topobjdir, 'test-defaults.pkl'), mode='rb') as fh:
-            pickle.dump(self._test_manager.manifest_defaults, fh, protocol=2)
-
-        path = mozpath.join(self.environment.topobjdir, 'test-installs.pkl')
-        with self._write_file(path, mode='rb') as fh:
-            pickle.dump({k: v for k, v in self._test_manager.installs_by_path.items()
-                         if k in self._test_manager.deferred_installs},
-                        fh,
-                        protocol=2)
-
-        # Write out a machine-readable file describing binaries.
         with self._write_file(mozpath.join(topobjdir, 'binaries.json')) as fh:
             d = {
                 'shared_libraries': [s.to_dict() for s in self._binaries.shared_libraries],
                 'programs': [p.to_dict() for p in self._binaries.programs],
             }
             json.dump(d, fh, sort_keys=True, indent=4)
 
     def _handle_webidl_collection(self, webidls):
--- a/python/mozbuild/mozbuild/backend/recursivemake.py
+++ b/python/mozbuild/mozbuild/backend/recursivemake.py
@@ -1,15 +1,16 @@
 # 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/.
 
 from __future__ import absolute_import, unicode_literals
 
 import logging
+import json
 import os
 import re
 
 from collections import (
     defaultdict,
     namedtuple,
 )
 from StringIO import StringIO
@@ -423,25 +424,22 @@ class RecursiveMakeBackend(CommonBackend
 
         if not isinstance(obj, ContextDerived):
             return False
 
         backend_file = self._get_backend_file_for(obj)
 
         consumed = CommonBackend.consume_object(self, obj)
 
-        # CommonBackend handles XPIDLFile and TestManifest, but we want to do
+        # CommonBackend handles XPIDLFile, but we want to do
         # some extra things for them.
         if isinstance(obj, XPIDLFile):
             backend_file.xpt_name = '%s.xpt' % obj.module
             self._idl_dirs.add(obj.relobjdir)
 
-        elif isinstance(obj, TestManifest):
-            self._process_test_manifest(obj, backend_file)
-
         # If CommonBackend acknowledged the object, we're done with it.
         if consumed:
             return True
 
         if not isinstance(obj, Defines):
             self.consume_object(obj.defines)
 
         if isinstance(obj, DirectoryTraversal):
@@ -622,16 +620,19 @@ class RecursiveMakeBackend(CommonBackend
         elif isinstance(obj, AndroidExtraPackages):
             # Order does not matter.
             for p in sorted(set(obj.packages)):
                 backend_file.write('ANDROID_EXTRA_PACKAGES += %s\n' % p)
 
         elif isinstance(obj, ChromeManifestEntry):
             self._process_chrome_manifest_entry(obj, backend_file)
 
+        elif isinstance(obj, TestManifest):
+            self._process_test_manifest(obj, backend_file)
+
         else:
             return False
 
         return True
 
     def _fill_root_mk(self):
         """
         Create two files, root.mk and root-deps.mk, the first containing
@@ -1049,57 +1050,20 @@ class RecursiveMakeBackend(CommonBackend
     def _process_host_simple_program(self, program, backend_file):
         backend_file.write('HOST_SIMPLE_PROGRAMS += %s\n' % program)
 
     def _process_test_manifest(self, obj, backend_file):
         # Much of the logic in this function could be moved to CommonBackend.
         self.backend_input_files.add(mozpath.join(obj.topsrcdir,
             obj.manifest_relpath))
 
-        # Don't allow files to be defined multiple times unless it is allowed.
-        # We currently allow duplicates for non-test files or test files if
-        # the manifest is listed as a duplicate.
-        for source, (dest, is_test) in obj.installs.items():
-            try:
-                self._install_manifests['_test_files'].add_symlink(source, dest)
-            except ValueError:
-                if not obj.dupe_manifest and is_test:
-                    raise
-
-        for base, pattern, dest in obj.pattern_installs:
-            try:
-                self._install_manifests['_test_files'].add_pattern_symlink(base,
-                    pattern, dest)
-            except ValueError:
-                if not obj.dupe_manifest:
-                    raise
-
-        for dest in obj.external_installs:
-            try:
-                self._install_manifests['_test_files'].add_optional_exists(dest)
-            except ValueError:
-                if not obj.dupe_manifest:
-                    raise
-
         m = self._test_manifests.setdefault(obj.flavor,
             (obj.install_prefix, set()))
         m[1].add(obj.manifest_obj_relpath)
 
-        try:
-            from reftest import ReftestManifest
-
-            if isinstance(obj.manifest, ReftestManifest):
-                # Mark included files as part of the build backend so changes
-                # result in re-config.
-                self.backend_input_files |= obj.manifest.manifests
-        except ImportError:
-            # Ignore errors caused by the reftest module not being present.
-            # This can happen when building SpiderMonkey standalone, for example.
-            pass
-
     def _process_local_include(self, local_include, backend_file):
         d, path = self._pretty_path_parts(local_include, backend_file)
         if isinstance(local_include, ObjDirPath) and not d:
             # path doesn't start with a slash in this case
             d = '$(CURDIR)/'
         elif d == '$(DEPTH)':
             d = '$(topobjdir)'
         quoted_path = shell_quote(path) if path else path
--- a/python/mozbuild/mozbuild/controller/building.py
+++ b/python/mozbuild/mozbuild/controller/building.py
@@ -665,16 +665,10 @@ class BuildDriver(MozbuildObject):
     def install_tests(self, test_objs):
         """Install test files."""
 
         if self.is_clobber_needed():
             print(INSTALL_TESTS_CLOBBER.format(
                   clobber_file=os.path.join(self.topobjdir, 'CLOBBER')))
             sys.exit(1)
 
-        if not test_objs:
-            # If we don't actually have a list of tests to install we install
-            # test and support files wholesale.
-            self._run_make(target='install-test-files', pass_thru=True,
-                           print_directory=False)
-        else:
-            install_test_files(mozpath.normpath(self.topsrcdir), self.topobjdir,
-                               '_tests', test_objs)
+        install_test_files(mozpath.normpath(self.topsrcdir), self.topobjdir,
+                           '_tests', test_objs)
--- a/python/mozbuild/mozbuild/frontend/context.py
+++ b/python/mozbuild/mozbuild/frontend/context.py
@@ -34,17 +34,16 @@ from mozbuild.util import (
     StrictOrderingOnAppendListWithAction,
     StrictOrderingOnAppendListWithFlagsFactory,
     TypedList,
     TypedNamedTuple,
 )
 
 from ..testing import (
     all_test_flavors,
-    read_manifestparser_manifest,
     read_reftest_manifest,
     read_wpt_manifest,
 )
 
 import mozpack.path as mozpath
 from types import FunctionType
 
 import itertools
@@ -611,17 +610,17 @@ def TypedListWithAction(typ, action):
             def _action(item):
                 return item, action(context, item)
             super(_TypedListWithAction, self).__init__(action=_action, *args)
     return _TypedListWithAction
 
 WebPlatformTestManifest = TypedNamedTuple("WebPlatformTestManifest",
                                           [("manifest_path", unicode),
                                            ("test_root", unicode)])
-ManifestparserManifestList = OrderedListWithAction(read_manifestparser_manifest)
+ManifestparserManifestList = ContextDerivedTypedList(SourcePath, StrictOrderingOnAppendList)
 ReftestManifestList = OrderedListWithAction(read_reftest_manifest)
 WptManifestList = TypedListWithAction(WebPlatformTestManifest, read_wpt_manifest)
 
 OrderedSourceList = ContextDerivedTypedList(SourcePath, StrictOrderingOnAppendList)
 OrderedTestFlavorList = TypedList(Enum(*all_test_flavors()),
                                   StrictOrderingOnAppendList)
 OrderedStringList = TypedList(unicode, StrictOrderingOnAppendList)
 DependentTestsEntry = ContextDerivedTypedRecord(('files', OrderedSourceList),
--- a/python/mozbuild/mozbuild/frontend/data.py
+++ b/python/mozbuild/mozbuild/frontend/data.py
@@ -591,85 +591,51 @@ class ExternalSharedLibrary(SharedLibrar
 
 
 class HostLibrary(HostMixin, BaseLibrary):
     """Context derived container object for a host library"""
     KIND = 'host'
 
 
 class TestManifest(ContextDerived):
-    """Represents a manifest file containing information about tests."""
+    """Represents a manifest file."""
 
     __slots__ = (
         # The type of test manifest this is.
         'flavor',
 
-        # Maps source filename to destination filename. The destination
-        # path is relative from the tests root directory. Values are 2-tuples
-        # of (destpath, is_test_file) where the 2nd item is True if this
-        # item represents a test file (versus a support file).
-        'installs',
-
-        # A list of pattern matching installs to perform. Entries are
-        # (base, pattern, dest).
-        'pattern_installs',
-
-        # Where all files for this manifest flavor are installed in the unified
-        # test package directory.
-        'install_prefix',
-
-        # Set of files provided by an external mechanism.
-        'external_installs',
-
-        # Set of files required by multiple test directories, whose installation
-        # will be resolved when running tests.
-        'deferred_installs',
-
         # The full path of this manifest file.
         'path',
 
         # The directory where this manifest is defined.
         'directory',
 
-        # The parsed manifestparser.TestManifest instance.
-        'manifest',
-
-        # List of tests. Each element is a dict of metadata.
-        'tests',
-
         # The relative path of the parsed manifest within the srcdir.
         'manifest_relpath',
 
         # The relative path of the parsed manifest within the objdir.
         'manifest_obj_relpath',
 
-        # If this manifest is a duplicate of another one, this is the
-        # manifestparser.TestManifest of the other one.
-        'dupe_manifest',
+        # Where all files for this manifest flavor are installed in the unified
+        # test package directory.
+        'install_prefix',
     )
 
-    def __init__(self, context, path, manifest, flavor=None,
-            install_prefix=None, relpath=None, dupe_manifest=False):
+    def __init__(self, context, path, flavor=None,
+            install_prefix=None, relpath=None):
         ContextDerived.__init__(self, context)
 
         assert flavor in all_test_flavors()
 
         self.path = path
         self.directory = mozpath.dirname(path)
-        self.manifest = manifest
         self.flavor = flavor
-        self.install_prefix = install_prefix
         self.manifest_relpath = relpath
         self.manifest_obj_relpath = relpath
-        self.dupe_manifest = dupe_manifest
-        self.installs = {}
-        self.pattern_installs = []
-        self.tests = []
-        self.external_installs = set()
-        self.deferred_installs = set()
+        self.install_prefix = install_prefix
 
 
 class LocalInclude(ContextDerived):
     """Describes an individual local include path."""
 
     __slots__ = (
         'path',
     )
--- a/python/mozbuild/mozbuild/frontend/emitter.py
+++ b/python/mozbuild/mozbuild/frontend/emitter.py
@@ -81,17 +81,16 @@ from mozpack.chrome.manifest import (
 )
 
 from .reader import SandboxValidationError
 
 from ..testing import (
     TEST_MANIFESTS,
     REFTEST_FLAVORS,
     WEB_PLATFORM_TESTS_FLAVORS,
-    SupportFilesConverter,
 )
 
 from .context import (
     Context,
     SourcePath,
     ObjDirPath,
     Path,
     SubContext,
@@ -199,17 +198,16 @@ class TreeMetadataEmitter(LoggingMixin):
         if os.path.exists(subconfigures):
             paths = open(subconfigures).read().splitlines()
         self._external_paths = set(mozpath.normsep(d) for d in paths)
         # Add security/nss manually, since it doesn't have a subconfigure.
         self._external_paths.add('security/nss')
 
         self._emitter_time = 0.0
         self._object_count = 0
-        self._test_files_converter = SupportFilesConverter()
 
     def summary(self):
         return ExecutionSummary(
             'Processed into {object_count:d} build config descriptors in '
             '{execution_time:.2f}s',
             execution_time=self._emitter_time,
             object_count=self._object_count)
 
@@ -1252,129 +1250,47 @@ class TreeMetadataEmitter(LoggingMixin):
                     inputs.append(p)
             else:
                 script = None
                 method = None
             yield GeneratedFile(context, script, method, outputs, inputs)
 
     def _process_test_manifests(self, context):
         for prefix, info in TEST_MANIFESTS.items():
-            for path, manifest in context.get('%s_MANIFESTS' % prefix, []):
-                for obj in self._process_test_manifest(context, info, path, manifest):
-                    yield obj
-
-        for flavor in REFTEST_FLAVORS:
-            for path, manifest in context.get('%s_MANIFESTS' % flavor.upper(), []):
-                for obj in self._process_reftest_manifest(context, flavor, path, manifest):
+            for path in context.get('%s_MANIFESTS' % prefix, []):
+                for obj in self._process_test_manifest(context, info, path):
                     yield obj
 
-        for flavor in WEB_PLATFORM_TESTS_FLAVORS:
-            for path, manifest in context.get("%s_MANIFESTS" % flavor.upper().replace('-', '_'), []):
-                for obj in self._process_web_platform_tests_manifest(context, path, manifest):
-                    yield obj
+        #for flavor in REFTEST_FLAVORS:
+        #    for path, manifest in context.get('%s_MANIFESTS' % flavor.upper(), []):
+        #        for obj in self._process_reftest_manifest(context, flavor, path, manifest):
+        #            yield obj
 
-        python_tests = context.get('PYTHON_UNIT_TESTS')
-        if python_tests:
-            for obj in self._process_python_tests(context, python_tests):
-                yield obj
+        #for flavor in WEB_PLATFORM_TESTS_FLAVORS:
+        #    for path, manifest in context.get("%s_MANIFESTS" % flavor.upper().replace('-', '_'), []):
+        #        for obj in self._process_web_platform_tests_manifest(context, path, manifest):
+        #            yield obj
 
-    def _process_test_manifest(self, context, info, manifest_path, mpmanifest):
+        #python_tests = context.get('PYTHON_UNIT_TESTS')
+        #if python_tests:
+        #    for obj in self._process_python_tests(context, python_tests):
+        #        yield obj
+
+    def _process_test_manifest(self, context, info, manifest_path):
         flavor, install_root, install_subdir, package_tests = info
 
         path = mozpath.normpath(mozpath.join(context.srcdir, manifest_path))
         manifest_dir = mozpath.dirname(path)
         manifest_reldir = mozpath.dirname(mozpath.relpath(path,
             context.config.topsrcdir))
         install_prefix = mozpath.join(install_root, install_subdir)
 
         try:
-            if not mpmanifest.tests:
-                raise SandboxValidationError('Empty test manifest: %s'
-                    % path, context)
-
-            defaults = mpmanifest.manifest_defaults[os.path.normpath(path)]
-            obj = TestManifest(context, path, mpmanifest, flavor=flavor,
-                install_prefix=install_prefix,
-                relpath=mozpath.join(manifest_reldir, mozpath.basename(path)),
-                dupe_manifest='dupe-manifest' in defaults)
-
-            filtered = mpmanifest.tests
-
-            # Jetpack add-on tests are expected to be generated during the
-            # build process so they won't exist here.
-            if flavor != 'jetpack-addon':
-                missing = [t['name'] for t in filtered if not os.path.exists(t['path'])]
-                if missing:
-                    raise SandboxValidationError('Test manifest (%s) lists '
-                        'test that does not exist: %s' % (
-                        path, ', '.join(missing)), context)
-
-            out_dir = mozpath.join(install_prefix, manifest_reldir)
-            if 'install-to-subdir' in defaults:
-                # This is terrible, but what are you going to do?
-                out_dir = mozpath.join(out_dir, defaults['install-to-subdir'])
-                obj.manifest_obj_relpath = mozpath.join(manifest_reldir,
-                                                        defaults['install-to-subdir'],
-                                                        mozpath.basename(path))
-
-            def process_support_files(test):
-                install_info = self._test_files_converter.convert_support_files(
-                    test, install_root, manifest_dir, out_dir)
-
-                obj.pattern_installs.extend(install_info.pattern_installs)
-                for source, dest in install_info.installs:
-                    obj.installs[source] = (dest, False)
-                obj.external_installs |= install_info.external_installs
-                for install_path in install_info.deferred_installs:
-                    if all(['*' not in install_path,
-                            not os.path.isfile(mozpath.join(context.config.topsrcdir,
-                                                            install_path[2:])),
-                            install_path not in install_info.external_installs]):
-                        raise SandboxValidationError('Error processing test '
-                           'manifest %s: entry in support-files not present '
-                           'in the srcdir: %s' % (path, install_path), context)
-
-                obj.deferred_installs |= install_info.deferred_installs
-
-            for test in filtered:
-                obj.tests.append(test)
-
-                # Some test files are compiled and should not be copied into the
-                # test package. They function as identifiers rather than files.
-                if package_tests:
-                    manifest_relpath = mozpath.relpath(test['path'],
-                        mozpath.dirname(test['manifest']))
-                    obj.installs[mozpath.normpath(test['path'])] = \
-                        ((mozpath.join(out_dir, manifest_relpath)), True)
-
-                process_support_files(test)
-
-            for path, m_defaults in mpmanifest.manifest_defaults.items():
-                process_support_files(m_defaults)
-
-            # We also copy manifests into the output directory,
-            # including manifests from [include:foo] directives.
-            for mpath in mpmanifest.manifests():
-                mpath = mozpath.normpath(mpath)
-                out_path = mozpath.join(out_dir, mozpath.basename(mpath))
-                obj.installs[mpath] = (out_path, False)
-
-            # Some manifests reference files that are auto generated as
-            # part of the build or shouldn't be installed for some
-            # reason. Here, we prune those files from the install set.
-            # FUTURE we should be able to detect autogenerated files from
-            # other build metadata. Once we do that, we can get rid of this.
-            for f in defaults.get('generated-files', '').split():
-                # We re-raise otherwise the stack trace isn't informative.
-                try:
-                    del obj.installs[mozpath.join(manifest_dir, f)]
-                except KeyError:
-                    raise SandboxValidationError('Error processing test '
-                        'manifest %s: entry in generated-files not present '
-                        'elsewhere in manifest: %s' % (path, f), context)
+            obj = TestManifest(context, path, flavor=flavor, install_prefix=install_prefix,
+                relpath=mozpath.join(manifest_reldir, mozpath.basename(path)))
 
             yield obj
         except (AssertionError, Exception):
             raise SandboxValidationError('Error processing test '
                 'manifest file %s: %s' % (path,
                     '\n'.join(traceback.format_exception(*sys.exc_info()))),
                 context)
 
@@ -1382,18 +1298,18 @@ class TreeMetadataEmitter(LoggingMixin):
         manifest_full_path = mozpath.normpath(mozpath.join(
             context.srcdir, manifest_path))
         manifest_reldir = mozpath.dirname(mozpath.relpath(manifest_full_path,
             context.config.topsrcdir))
 
         # reftest manifests don't come from manifest parser. But they are
         # similar enough that we can use the same emitted objects. Note
         # that we don't perform any installs for reftests.
-        obj = TestManifest(context, manifest_full_path, manifest,
-                flavor=flavor, install_prefix='%s/' % flavor,
+        obj = TestManifest(context, manifest_full_path,
+                manifest, flavor=flavor, install_prefix='%s/' % flavor,
                 relpath=mozpath.join(manifest_reldir,
                     mozpath.basename(manifest_path)))
 
         for test, source_manifest in sorted(manifest.tests):
             obj.tests.append({
                 'path': test,
                 'here': mozpath.dirname(test),
                 'manifest': source_manifest,
--- a/python/mozbuild/mozbuild/test/test_testing.py
+++ b/python/mozbuild/mozbuild/test/test_testing.py
@@ -11,20 +11,18 @@ import tempfile
 import unittest
 
 import mozpack.path as mozpath
 
 from mozfile import NamedTemporaryFile
 from mozunit import main
 
 from mozbuild.base import MozbuildObject
-from mozbuild.testing import (
-    TestMetadata,
-    TestResolver,
-)
+from mozbuild.testing import TestResolver
+from mozpack.resolve import TestResolver as TestMetadata
 
 
 ALL_TESTS = {
     "accessible/tests/mochitest/actions/test_anchors.html": [
         {
             "dir_relpath": "accessible/tests/mochitest/actions",
             "expected": "pass",
             "file_relpath": "accessible/tests/mochitest/actions/test_anchors.html",
@@ -156,102 +154,43 @@ ALL_TESTS = {
    ]
 }
 
 TEST_DEFAULTS = {
     "/Users/gps/src/firefox/toolkit/mozapps/update/test/unit/xpcshell_updater.ini": {"support-files": "\ndata/**\nxpcshell_updater.ini"}
 }
 
 
-class Base(unittest.TestCase):
+class TestTestResolver(unittest.TestCase):
+    FAKE_TOPSRCDIR = '/Users/gps/src/firefox'
+
     def setUp(self):
+        self._temp_dirs = []
         self._temp_files = []
 
     def tearDown(self):
         for f in self._temp_files:
             del f
 
-        self._temp_files = []
+        for d in self._temp_dirs:
+            shutil.rmtree(d)
 
     def _get_test_metadata(self):
         all_tests = NamedTemporaryFile(mode='wb')
         pickle.dump(ALL_TESTS, all_tests)
         all_tests.flush()
         self._temp_files.append(all_tests)
 
         test_defaults = NamedTemporaryFile(mode='wb')
         pickle.dump(TEST_DEFAULTS, test_defaults)
         test_defaults.flush()
         self._temp_files.append(test_defaults)
 
         return TestMetadata(all_tests.name, test_defaults=test_defaults.name)
 
-
-class TestTestMetadata(Base):
-    def test_load(self):
-        t = self._get_test_metadata()
-        self.assertEqual(len(t._tests_by_path), 8)
-
-        self.assertEqual(len(list(t.tests_with_flavor('xpcshell'))), 3)
-        self.assertEqual(len(list(t.tests_with_flavor('mochitest-plain'))), 0)
-
-    def test_resolve_all(self):
-        t = self._get_test_metadata()
-        self.assertEqual(len(list(t.resolve_tests())), 9)
-
-    def test_resolve_filter_flavor(self):
-        t = self._get_test_metadata()
-        self.assertEqual(len(list(t.resolve_tests(flavor='xpcshell'))), 4)
-
-    def test_resolve_by_dir(self):
-        t = self._get_test_metadata()
-        self.assertEqual(len(list(t.resolve_tests(paths=['services/common']))), 2)
-
-    def test_resolve_under_path(self):
-        t = self._get_test_metadata()
-        self.assertEqual(len(list(t.resolve_tests(under_path='services'))), 2)
-
-        self.assertEqual(len(list(t.resolve_tests(flavor='xpcshell',
-            under_path='services'))), 2)
-
-    def test_resolve_multiple_paths(self):
-        t = self._get_test_metadata()
-        result = list(t.resolve_tests(paths=['services', 'toolkit']))
-        self.assertEqual(len(result), 4)
-
-    def test_resolve_support_files(self):
-        expected_support_files = "\ndata/**\nxpcshell_updater.ini"
-        t = self._get_test_metadata()
-        result = list(t.resolve_tests(paths=['toolkit']))
-        self.assertEqual(len(result), 2)
-
-        for test in result:
-            self.assertEqual(test['support-files'],
-                             expected_support_files)
-
-    def test_resolve_path_prefix(self):
-        t = self._get_test_metadata()
-        result = list(t.resolve_tests(paths=['image']))
-        self.assertEqual(len(result), 1)
-
-
-class TestTestResolver(Base):
-    FAKE_TOPSRCDIR = '/Users/gps/src/firefox'
-
-    def setUp(self):
-        Base.setUp(self)
-
-        self._temp_dirs = []
-
-    def tearDown(self):
-        Base.tearDown(self)
-
-        for d in self._temp_dirs:
-            shutil.rmtree(d)
-
     def _get_resolver(self):
         topobjdir = tempfile.mkdtemp()
         self._temp_dirs.append(topobjdir)
 
         with open(os.path.join(topobjdir, 'all-tests.pkl'), 'wb') as fh:
             pickle.dump(ALL_TESTS, fh)
         with open(os.path.join(topobjdir, 'test-defaults.pkl'), 'wb') as fh:
             pickle.dump(TEST_DEFAULTS, fh)
@@ -259,47 +198,16 @@ class TestTestResolver(Base):
         o = MozbuildObject(self.FAKE_TOPSRCDIR, None, None, topobjdir=topobjdir)
 
         # Monkey patch the test resolver to avoid tests failing to find make
         # due to our fake topscrdir.
         TestResolver._run_make = lambda *a, **b: None
 
         return o._spawn(TestResolver)
 
-    def test_cwd_children_only(self):
-        """If cwd is defined, only resolve tests under the specified cwd."""
-        r = self._get_resolver()
-
-        # Pretend we're under '/services' and ask for 'common'. This should
-        # pick up all tests from '/services/common'
-        tests = list(r.resolve_tests(paths=['common'], cwd=os.path.join(r.topsrcdir,
-            'services')))
-
-        self.assertEqual(len(tests), 2)
-
-        # Tests should be rewritten to objdir.
-        for t in tests:
-            self.assertEqual(t['here'], mozpath.join(r.topobjdir,
-                '_tests/xpcshell/services/common/tests/unit'))
-
-    def test_various_cwd(self):
-        """Test various cwd conditions are all equal."""
-
-        r = self._get_resolver()
-
-        expected = list(r.resolve_tests(paths=['services']))
-        actual = list(r.resolve_tests(paths=['services'], cwd='/'))
-        self.assertEqual(actual, expected)
-
-        actual = list(r.resolve_tests(paths=['services'], cwd=r.topsrcdir))
-        self.assertEqual(actual, expected)
-
-        actual = list(r.resolve_tests(paths=['services'], cwd=r.topobjdir))
-        self.assertEqual(actual, expected)
-
     def test_subsuites(self):
         """Test filtering by subsuite."""
 
         r = self._get_resolver()
 
         tests = list(r.resolve_tests(paths=['mobile']))
         self.assertEqual(len(tests), 2)
 
--- a/python/mozbuild/mozbuild/testing.py
+++ b/python/mozbuild/mozbuild/testing.py
@@ -2,196 +2,67 @@
 # 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/.
 
 from __future__ import absolute_import, unicode_literals
 
 import cPickle as pickle
 import os
 import sys
+from collections import defaultdict
 
 import mozpack.path as mozpath
 
 from mozpack.copier import FileCopier
 from mozpack.manifests import InstallManifest
+from mozpack.resolve import TestResolver as TestMetadata
 
 from .base import MozbuildObject
-from .util import OrderedDefaultDict
-from collections import defaultdict
 
 import manifestparser
+import mozinfo
+
 
 def rewrite_test_base(test, new_base, honor_install_to_subdir=False):
     """Rewrite paths in a test to be under a new base path.
 
     This is useful for running tests from a separate location from where they
     were defined.
 
     honor_install_to_subdir and the underlying install-to-subdir field are a
     giant hack intended to work around the restriction where the mochitest
     runner can't handle single test files with multiple configurations. This
     argument should be removed once the mochitest runner talks manifests
     (bug 984670).
     """
-    test['here'] = mozpath.join(new_base, test['dir_relpath'])
+    dir_relpath = mozpath.dirname(test['relpath'])
+    test['here'] = mozpath.join(new_base, dir_relpath)
 
     if honor_install_to_subdir and test.get('install-to-subdir'):
         manifest_relpath = mozpath.relpath(test['path'],
             mozpath.dirname(test['manifest']))
-        test['path'] = mozpath.join(new_base, test['dir_relpath'],
+        test['path'] = mozpath.join(new_base, dir_relpath,
             test['install-to-subdir'], manifest_relpath)
+        dirpath, base = test['relpath'].split()
+        test['relpath'] = mozpath.join(dirpath,
+            test['install-to-subdir'], base)
     else:
-        test['path'] = mozpath.join(new_base, test['file_relpath'])
+        test['path'] = mozpath.join(new_base, test['relpath'])
 
     return test
 
 
-class TestMetadata(object):
-    """Holds information about tests.
-
-    This class provides an API to query tests active in the build
-    configuration.
-    """
-
-    def __init__(self, all_tests, test_defaults=None):
-        self._tests_by_path = OrderedDefaultDict(list)
-        self._tests_by_flavor = defaultdict(set)
-        self._test_dirs = set()
-
-        with open(all_tests, 'rb') as fh:
-            test_data = pickle.load(fh)
-        defaults = None
-        if test_defaults:
-            with open(test_defaults, 'rb') as fh:
-                defaults = pickle.load(fh)
-        for path, tests in test_data.items():
-            for metadata in tests:
-                if defaults:
-                    manifest = metadata['manifest']
-                    manifest_defaults = defaults.get(manifest)
-                    if manifest_defaults:
-                        metadata = manifestparser.combine_fields(manifest_defaults,
-                                                                 metadata)
-                self._tests_by_path[path].append(metadata)
-                self._test_dirs.add(os.path.dirname(path))
-                flavor = metadata.get('flavor')
-                self._tests_by_flavor[flavor].add(path)
-
-    def tests_with_flavor(self, flavor):
-        """Obtain all tests having the specified flavor.
-
-        This is a generator of dicts describing each test.
-        """
-
-        for path in sorted(self._tests_by_flavor.get(flavor, [])):
-            yield self._tests_by_path[path]
-
-    def resolve_tests(self, paths=None, flavor=None, subsuite=None, under_path=None,
-                      tags=None):
-        """Resolve tests from an identifier.
-
-        This is a generator of dicts describing each test.
-
-        ``paths`` can be an iterable of values to use to identify tests to run.
-        If an entry is a known test file, tests associated with that file are
-        returned (there may be multiple configurations for a single file). If
-        an entry is a directory, or a prefix of a directory containing tests,
-        all tests in that directory are returned. If the string appears in a
-        known test file, that test file is considered. If the path contains
-        a wildcard pattern, tests matching that pattern are returned.
-
-        If ``under_path`` is a string, it will be used to filter out tests that
-        aren't in the specified path prefix relative to topsrcdir or the
-        test's installed dir.
-
-        If ``flavor`` is a string, it will be used to filter returned tests
-        to only be the flavor specified. A flavor is something like
-        ``xpcshell``.
-
-        If ``subsuite`` is a string, it will be used to filter returned tests
-        to only be in the subsuite specified.
-
-        If ``tags`` are specified, they will be used to filter returned tests
-        to only those with a matching tag.
-        """
-        if tags:
-            tags = set(tags)
-
-        def fltr(tests):
-            for test in tests:
-                if flavor:
-                   if (flavor == 'devtools' and test.get('flavor') != 'browser-chrome') or \
-                      (flavor != 'devtools' and test.get('flavor') != flavor):
-                    continue
-
-                if subsuite and test.get('subsuite') != subsuite:
-                    continue
-
-                if tags and not (tags & set(test.get('tags', '').split())):
-                    continue
-
-                if under_path \
-                    and not test['file_relpath'].startswith(under_path):
-                    continue
-
-                # Make a copy so modifications don't change the source.
-                yield dict(test)
-
-        paths = paths or []
-        paths = [mozpath.normpath(p) for p in paths]
-        if not paths:
-            paths = [None]
-
-        candidate_paths = set()
-
-        for path in sorted(paths):
-            if path is None:
-                candidate_paths |= set(self._tests_by_path.keys())
-                continue
-
-            if '*' in path:
-                candidate_paths |= {p for p in self._tests_by_path
-                                    if mozpath.match(p, path)}
-                continue
-
-            # If the path is a directory, or the path is a prefix of a directory
-            # containing tests, pull in all tests in that directory.
-            if (path in self._test_dirs or
-                any(p.startswith(path) for p in self._tests_by_path)):
-                candidate_paths |= {p for p in self._tests_by_path
-                                    if p.startswith(path)}
-                continue
-
-            # If it's a test file, add just that file.
-            candidate_paths |= {p for p in self._tests_by_path if path in p}
-
-        for p in sorted(candidate_paths):
-            tests = self._tests_by_path[p]
-
-            for test in fltr(tests):
-                yield test
-
-
 class TestResolver(MozbuildObject):
     """Helper to resolve tests from the current environment to test files."""
 
     def __init__(self, *args, **kwargs):
         MozbuildObject.__init__(self, *args, **kwargs)
 
-        # If installing tests is going to result in re-generating the build
-        # backend, we need to do this here, so that the updated contents of
-        # all-tests.pkl make it to the set of tests to run.
-        self._run_make(target='run-tests-deps', pass_thru=True,
-                       print_directory=False)
-
-        self._tests = TestMetadata(os.path.join(self.topobjdir,
-                                                'all-tests.pkl'),
-                                   test_defaults=os.path.join(self.topobjdir,
-                                                              'test-defaults.pkl'))
-
+        self._tests = TestMetadata(manifests_by_flavor=os.path.join(
+            self.topobjdir, 'root-manifests.json'), root=self.topsrcdir)
         self._test_rewrites = {
             'a11y': os.path.join(self.topobjdir, '_tests', 'testing',
                 'mochitest', 'a11y'),
             'browser-chrome': os.path.join(self.topobjdir, '_tests', 'testing',
                 'mochitest', 'browser'),
             'jetpack-package': os.path.join(self.topobjdir, '_tests', 'testing',
                 'mochitest', 'jetpack-package'),
             'jetpack-addon': os.path.join(self.topobjdir, '_tests', 'testing',
@@ -200,59 +71,45 @@ class TestResolver(MozbuildObject):
                 'mochitest', 'chrome'),
             'mochitest': os.path.join(self.topobjdir, '_tests', 'testing',
                 'mochitest', 'tests'),
             'web-platform-tests': os.path.join(self.topobjdir, '_tests', 'testing',
                                                'web-platform'),
             'xpcshell': os.path.join(self.topobjdir, '_tests', 'xpcshell'),
         }
 
-    def resolve_tests(self, cwd=None, **kwargs):
+        mozinfo.find_and_update_from_json(self.topobjdir)
+
+    def resolve_tests(self, **kwargs):
         """Resolve tests in the context of the current environment.
 
-        This is a more intelligent version of TestMetadata.resolve_tests().
+        This is a more intelligent version of TestMetadata.resolve().
 
         This function provides additional massaging and filtering of low-level
         results.
 
         Paths in returned tests are automatically translated to the paths in
         the _tests directory under the object directory.
-
-        If cwd is defined, we will limit our results to tests under the
-        directory specified. The directory should be defined as an absolute
-        path under topsrcdir or topobjdir for it to work properly.
         """
-        rewrite_base = None
 
-        if cwd:
-            norm_cwd = mozpath.normpath(cwd)
-            norm_srcdir = mozpath.normpath(self.topsrcdir)
-            norm_objdir = mozpath.normpath(self.topobjdir)
-
-            reldir = None
+        def rewrite_base(tests, values):
+            for test in tests:
+                flavor = test.get('flavor')
+                rewrite_base = self._test_rewrites.get(flavor)
 
-            if norm_cwd.startswith(norm_objdir):
-                reldir = norm_cwd[len(norm_objdir)+1:]
-            elif norm_cwd.startswith(norm_srcdir):
-                reldir = norm_cwd[len(norm_srcdir)+1:]
-
-            result = self._tests.resolve_tests(under_path=reldir,
-                **kwargs)
+                if rewrite_base:
+                    yield rewrite_test_base(test, rewrite_base,
+                        honor_install_to_subdir=True)
+                else:
+                    yield test
 
-        else:
-            result = self._tests.resolve_tests(**kwargs)
-
-        for test in result:
-            rewrite_base = self._test_rewrites.get(test['flavor'], None)
+        return (t for t in self._tests.resolve(
+            filters=[rewrite_base], **kwargs))
 
-            if rewrite_base:
-                yield rewrite_test_base(test, rewrite_base,
-                    honor_install_to_subdir=True)
-            else:
-                yield test
+
 
 # These definitions provide a single source of truth for modules attempting
 # to get a view of all tests for a build. Used by the emitter to figure out
 # how to read/install manifests and by test dependency annotations in Files()
 # entries to enumerate test flavors.
 
 # While there are multiple test manifests, the behavior is very similar
 # across them. We enforce this by having common handling of all
@@ -307,59 +164,57 @@ def all_test_flavors():
     return ([v[0] for v in TEST_MANIFESTS.values()] +
             list(REFTEST_FLAVORS) +
             list(WEB_PLATFORM_TESTS_FLAVORS) +
             ['python'])
 
 class TestInstallInfo(object):
     def __init__(self):
         self.seen = set()
-        self.pattern_installs = []
         self.installs = []
         self.external_installs = set()
-        self.deferred_installs = set()
 
     def __ior__(self, other):
-        self.pattern_installs.extend(other.pattern_installs)
         self.installs.extend(other.installs)
         self.external_installs |= other.external_installs
-        self.deferred_installs |= other.deferred_installs
         return self
 
 class SupportFilesConverter(object):
     """Processes a "support-files" entry from a test object, either from
     a parsed object from a test manifests or its representation in
     moz.build and returns the installs to perform for this test object.
 
     Processing the same support files multiple times will not have any further
     effect, and the structure of the parsed objects from manifests will have a
     lot of repeated entries, so this class takes care of memoizing.
     """
-    def __init__(self):
+    def __init__(self, topsrcdir):
+        self.topsrcdir = topsrcdir
         self._fields = (('head', set()),
                         ('tail', set()),
                         ('support-files', set()),
                         ('generated-files', set()))
 
+
     def convert_support_files(self, test, install_root, manifest_dir, out_dir):
         # Arguments:
         #  test - The test object to process.
         #  install_root - The directory under $objdir/_tests that will contain
         #                 the tests for this harness (examples are "testing/mochitest",
         #                 "xpcshell").
         #  manifest_dir - Absoulute path to the (srcdir) directory containing the
         #                 manifest that included this test
         #  out_dir - The path relative to $objdir/_tests used as the destination for the
         #            test, based on the relative path to the manifest in the srcdir,
         #            the install_root, and 'install-to-subdir', if present in the manifest.
+
         info = TestInstallInfo()
         for field, seen in self._fields:
             value = test.get(field, '')
             for pattern in value.split():
-
                 # We track uniqueness locally (per test) where duplicates are forbidden,
                 # and globally, where they are permitted. If a support file appears multiple
                 # times for a single test, there are unnecessary entries in the manifest. But
                 # many entries will be shared across tests that share defaults.
                 # We need to memoize on the basis of both the path and the output
                 # directory for the benefit of tests specifying 'install-to-subdir'.
                 key = field, pattern, out_dir
                 if key in info.seen:
@@ -367,32 +222,33 @@ class SupportFilesConverter(object):
                                      " please omit the duplicate entry." % (pattern, field))
                 info.seen.add(key)
                 if key in seen:
                     continue
                 seen.add(key)
 
                 if field == 'generated-files':
                     info.external_installs.add(mozpath.normpath(mozpath.join(out_dir, pattern)))
-                # '!' indicates our syntax for inter-directory support file
-                # dependencies. These receive special handling in the backend.
-                elif pattern[0] == '!':
-                    info.deferred_installs.add(pattern)
-                # We only support globbing on support-files because
-                # the harness doesn't support * for head and tail.
-                elif '*' in pattern and field == 'support-files':
-                    info.pattern_installs.append((manifest_dir, pattern, out_dir))
+                    continue
+
+                source = manifest_dir
+                if pattern[0] == '!':
+                    source = self.topsrcdir
+                    pattern = pattern[2:]
+
+                if '*' in pattern:
+                    info.installs.append((source, pattern, out_dir, True))
                 # "absolute" paths identify files that are to be
                 # placed in the install_root directory (no globs)
                 elif pattern[0] == '/':
-                    full = mozpath.normpath(mozpath.join(manifest_dir,
+                    full = mozpath.normpath(mozpath.join(source,
                                                          mozpath.basename(pattern)))
-                    info.installs.append((full, mozpath.join(install_root, pattern[1:])))
+                    info.installs.append((full, mozpath.join(install_root, pattern[1:]), True))
                 else:
-                    full = mozpath.normpath(mozpath.join(manifest_dir, pattern))
+                    full = mozpath.normpath(mozpath.join(source, pattern))
                     dest_path = mozpath.join(out_dir, pattern)
 
                     # If the path resolves to a different directory
                     # tree, we take special behavior depending on the
                     # entry type.
                     if not full.startswith(manifest_dir):
                         # If it's a support file, we install the file
                         # into the current destination directory.
@@ -404,122 +260,100 @@ class SupportFilesConverter(object):
                             dest_path = mozpath.join(out_dir,
                                                      os.path.basename(pattern))
                         # If it's not a support file, we ignore it.
                         # This preserves old behavior so things like
                         # head files doesn't get installed multiple
                         # times.
                         else:
                             continue
-                    info.installs.append((full, mozpath.normpath(dest_path)))
+                    info.installs.append((full, mozpath.normpath(dest_path), True))
         return info
 
-def _resolve_installs(paths, topobjdir, manifest):
-    """Using the given paths as keys, find any unresolved installs noted
-    by the build backend corresponding to those keys, and add them
-    to the given manifest.
-    """
-    filename = os.path.join(topobjdir, 'test-installs.pkl')
-    with open(filename, 'rb') as fh:
-        resolved_installs = pickle.load(fh)
-
-    for path in paths:
-        path = path[2:]
-        if path not in resolved_installs:
-            raise Exception('A cross-directory support file path noted in a '
-                'test manifest does not appear in any other manifest.\n "%s" '
-                'must appear in another test manifest to specify an install '
-                'for "!/%s".' % (path, path))
-        installs = resolved_installs[path]
-        for install_info in installs:
-            try:
-                if len(install_info) == 3:
-                    manifest.add_pattern_symlink(*install_info)
-                if len(install_info) == 2:
-                    manifest.add_symlink(*install_info)
-            except ValueError:
-                # A duplicate value here is pretty likely when running
-                # multiple directories at once, and harmless.
-                pass
 
 def install_test_files(topsrcdir, topobjdir, tests_root, test_objs):
     """Installs the requested test files to the objdir. This is invoked by
     test runners to avoid installing tens of thousands of test files when
     only a few tests need to be run.
     """
     flavor_info = {flavor: (root, prefix, install)
                    for (flavor, root, prefix, install) in TEST_MANIFESTS.values()}
     objdir_dest = mozpath.join(topobjdir, tests_root)
 
-    converter = SupportFilesConverter()
+    converter = SupportFilesConverter(topsrcdir)
     install_info = TestInstallInfo()
     for o in test_objs:
-        flavor = o['flavor']
+        flavor = o.get('flavor')
         if flavor not in flavor_info:
             # This is a test flavor that isn't installed by the build system.
             continue
         root, prefix, install = flavor_info[flavor]
         if not install:
             # This flavor isn't installed to the objdir.
             continue
 
         manifest_path = o['manifest']
         manifest_dir = mozpath.dirname(manifest_path)
 
         out_dir = mozpath.join(root, prefix, manifest_dir[len(topsrcdir) + 1:])
-        file_relpath = o['file_relpath']
+        file_relpath = o['relpath']
         source = mozpath.join(topsrcdir, file_relpath)
         dest = mozpath.join(root, prefix, file_relpath)
+
         if 'install-to-subdir' in o:
             out_dir = mozpath.join(out_dir, o['install-to-subdir'])
             manifest_relpath = mozpath.relpath(source, mozpath.dirname(manifest_path))
             dest = mozpath.join(out_dir, manifest_relpath)
 
-        install_info.installs.append((source, dest))
+        install_info.installs.append((source, dest, 'dupe_manifest' in o))
         install_info |= converter.convert_support_files(o, root,
                                                         manifest_dir,
                                                         out_dir)
 
     manifest = InstallManifest()
 
-    for source, dest in set(install_info.installs):
-        if dest in install_info.external_installs:
-            continue
-        manifest.add_symlink(source, dest)
-    for base, pattern, dest in install_info.pattern_installs:
-        manifest.add_pattern_symlink(base, pattern, dest)
-
-    _resolve_installs(install_info.deferred_installs, topobjdir, manifest)
+    for install in set(install_info.installs):
+        if len(install) == 3:
+            source, dest, ignore_duplicates = install
+            if dest in install_info.external_installs:
+                continue
+            try:
+                manifest.add_symlink(source, dest)
+            except ValueError:
+                if not ignore_duplicates:
+                    raise
+        elif len(install) == 4:
+            base, pattern, dest, ignore_duplicates = install
+            try:
+                manifest.add_pattern_symlink(base, pattern, dest)
+            except ValueError:
+                if not ignore_duplicates:
+                    raise
 
     # Harness files are treated as a monolith and installed each time we run tests.
     # Fortunately there are not very many.
     manifest |= InstallManifest(mozpath.join(topobjdir,
                                              '_build_manifests',
                                              'install', tests_root))
     copier = FileCopier()
     manifest.populate_registry(copier)
     copier.copy(objdir_dest,
                 remove_unaccounted=False)
 
 
 # Convenience methods for test manifest reading.
-def read_manifestparser_manifest(context, manifest_path):
-    path = mozpath.normpath(mozpath.join(context.srcdir, manifest_path))
-    return manifestparser.TestManifest(manifests=[path], strict=True,
-                                       rootdir=context.config.topsrcdir,
-                                       finder=context._finder,
-                                       handle_defaults=False)
 
 def read_reftest_manifest(context, manifest_path):
     import reftest
     path = mozpath.normpath(mozpath.join(context.srcdir, manifest_path))
     manifest = reftest.ReftestManifest(finder=context._finder)
     manifest.load(path)
     return manifest
 
+
 def read_wpt_manifest(context, paths):
     manifest_path, tests_root = paths
     full_path = mozpath.normpath(mozpath.join(context.srcdir, manifest_path))
     old_path = sys.path[:]
     try:
         # Setup sys.path to include all the dependencies required to import
         # the web-platform-tests manifest parser. web-platform-tests provides
         # a the localpaths.py to do the path manipulation, which we load,
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozpack/resolve.py
@@ -0,0 +1,78 @@
+# 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 json
+import os
+from abc import ABCMeta, abstractmethod
+from collections import defaultdict
+
+import mozinfo
+from manifestparser import (
+    TestManifest,
+    filters as mpf,
+)
+
+import mozpack.path as mozpath
+from mozbuild.util import OrderedDefaultDict
+
+
+class TestResolver(object):
+    """Resolves tests using ManifestParser
+
+    This is used by mozbuild to resolve tests out of the generated
+    'all-tests.json' file that lives in the objdir.
+    """
+
+    def __init__(self, manifests_by_flavor=None, root=None):
+        self.root = root
+        self.manifests_by_flavor = manifests_by_flavor or {}
+        if manifests_by_flavor:
+            with open(manifests_by_flavor, 'rt') as fh:
+                self.manifests_by_flavor = json.load(fh)
+
+    def resolve(self, paths=None, flavors=None, subsuite=None, tags=None, filters=None):
+        """Resolve tests from an identifier.
+
+        This is a generator of dicts describing each test.
+
+        If ``flavor`` is a string, it will be used to filter returned tests
+        to only be the flavor specified. A flavor is something like
+        ``xpcshell``.
+
+        ``paths`` can be an iterable of values to use to identify tests to run.
+        If an entry is a known test file, tests associated with that file are
+        returned (there may be multiple configurations for a single file). If
+        an entry is a directory, or a prefix of a directory containing tests,
+        all tests in that directory are returned. If the string appears in a
+        known test file, that test file is considered. If the path contains
+        a wildcard pattern, tests matching that pattern are returned.
+
+        If ``subsuite`` is a string, it will be used to filter returned tests
+        to only be in the subsuite specified.
+
+        If ``tags`` are specified, they will be used to filter returned tests
+        to only those with a matching tag.
+        """
+        if isinstance(flavors, basestring):
+            flavors = [flavors]
+        else:
+            flavors = flavors or self.manifests_by_flavors.keys()
+
+        filters = filters or []
+        if tags:
+            filters.insert(0, mpf.tags(tags))
+
+        if subsuite:
+            filters.insert(0, mpf.subsuite(subsuite))
+
+        if paths:
+            filters.insert(0, mpf.pathprefix(paths))
+
+        defaults = {}
+        for flavor in flavors:
+            defaults['flavor'] = flavor
+            mp = TestManifest(manifests=(self.manifests_by_flavor[flavor],), defaults=defaults, rootdir=self.root, strict=False)
+            tests = mp.active_tests(filters=filters, **mozinfo.info)
+            for test in tests:
+                yield test
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozpack/test/test_resolve.py
@@ -0,0 +1,213 @@
+# 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/.
+# flake8: noqa
+
+from __future__ import unicode_literals
+
+import unittest
+
+from mozfile import NamedTemporaryFile
+from mozunit import main
+
+from mozpack.resolve import TestResolver
+
+
+ALL_TESTS_JSON = b'''
+[{
+    "accessible/tests/mochitest/actions/test_anchors.html": [
+        {
+            "dir_relpath": "accessible/tests/mochitest/actions",
+            "expected": "pass",
+            "file_relpath": "accessible/tests/mochitest/actions/test_anchors.html",
+            "flavor": "a11y",
+            "here": "/Users/gps/src/firefox/accessible/tests/mochitest/actions",
+            "manifest": "/Users/gps/src/firefox/accessible/tests/mochitest/actions/a11y.ini",
+            "name": "test_anchors.html",
+            "path": "/Users/gps/src/firefox/accessible/tests/mochitest/actions/test_anchors.html",
+            "relpath": "test_anchors.html"
+        }
+    ],
+    "services/common/tests/unit/test_async_chain.js": [
+        {
+            "dir_relpath": "services/common/tests/unit",
+            "file_relpath": "services/common/tests/unit/test_async_chain.js",
+            "firefox-appdir": "browser",
+            "flavor": "xpcshell",
+            "head": "head_global.js head_helpers.js head_http.js",
+            "here": "/Users/gps/src/firefox/services/common/tests/unit",
+            "manifest": "/Users/gps/src/firefox/services/common/tests/unit/xpcshell.ini",
+            "name": "test_async_chain.js",
+            "path": "/Users/gps/src/firefox/services/common/tests/unit/test_async_chain.js",
+            "relpath": "test_async_chain.js",
+            "tail": ""
+        }
+    ],
+    "services/common/tests/unit/test_async_querySpinningly.js": [
+        {
+            "dir_relpath": "services/common/tests/unit",
+            "file_relpath": "services/common/tests/unit/test_async_querySpinningly.js",
+            "firefox-appdir": "browser",
+            "flavor": "xpcshell",
+            "head": "head_global.js head_helpers.js head_http.js",
+            "here": "/Users/gps/src/firefox/services/common/tests/unit",
+            "manifest": "/Users/gps/src/firefox/services/common/tests/unit/xpcshell.ini",
+            "name": "test_async_querySpinningly.js",
+            "path": "/Users/gps/src/firefox/services/common/tests/unit/test_async_querySpinningly.js",
+            "relpath": "test_async_querySpinningly.js",
+            "tail": ""
+        }
+    ],
+   "toolkit/mozapps/update/test/unit/test_0201_app_launch_apply_update.js": [
+        {
+            "dir_relpath": "toolkit/mozapps/update/test/unit",
+            "file_relpath": "toolkit/mozapps/update/test/unit/test_0201_app_launch_apply_update.js",
+            "flavor": "xpcshell",
+            "generated-files": "head_update.js",
+            "head": "head_update.js",
+            "here": "/Users/gps/src/firefox/toolkit/mozapps/update/test/unit",
+            "manifest": "/Users/gps/src/firefox/toolkit/mozapps/update/test/unit/xpcshell_updater.ini",
+            "name": "test_0201_app_launch_apply_update.js",
+            "path": "/Users/gps/src/firefox/toolkit/mozapps/update/test/unit/test_0201_app_launch_apply_update.js",
+            "reason": "bug 820380",
+            "relpath": "test_0201_app_launch_apply_update.js",
+            "run-sequentially": "Launches application.",
+            "skip-if": "toolkit == 'gonk' || os == 'android'",
+            "tail": ""
+        },
+        {
+            "dir_relpath": "toolkit/mozapps/update/test/unit",
+            "file_relpath": "toolkit/mozapps/update/test/unit/test_0201_app_launch_apply_update.js",
+            "flavor": "xpcshell",
+            "generated-files": "head_update.js",
+            "head": "head_update.js head2.js",
+            "here": "/Users/gps/src/firefox/toolkit/mozapps/update/test/unit",
+            "manifest": "/Users/gps/src/firefox/toolkit/mozapps/update/test/unit/xpcshell_updater.ini",
+            "name": "test_0201_app_launch_apply_update.js",
+            "path": "/Users/gps/src/firefox/toolkit/mozapps/update/test/unit/test_0201_app_launch_apply_update.js",
+            "reason": "bug 820380",
+            "relpath": "test_0201_app_launch_apply_update.js",
+            "run-sequentially": "Launches application.",
+            "skip-if": "toolkit == 'gonk' || os == 'android'",
+            "tail": ""
+        }
+    ],
+    "mobile/android/tests/background/junit3/src/common/TestAndroidLogWriters.java": [
+        {
+            "dir_relpath": "mobile/android/tests/background/junit3/src/common",
+            "file_relpath": "mobile/android/tests/background/junit3/src/common/TestAndroidLogWriters.java",
+            "flavor": "instrumentation",
+            "here": "/Users/nalexander/Mozilla/gecko-dev/mobile/android/tests/background/junit3",
+            "manifest": "/Users/nalexander/Mozilla/gecko-dev/mobile/android/tests/background/junit3/instrumentation.ini",
+            "name": "src/common/TestAndroidLogWriters.java",
+            "path": "/Users/nalexander/Mozilla/gecko-dev/mobile/android/tests/background/junit3/src/common/TestAndroidLogWriters.java",
+            "relpath": "src/common/TestAndroidLogWriters.java",
+            "subsuite": "background"
+        }
+    ],
+    "mobile/android/tests/browser/junit3/src/TestDistribution.java": [
+        {
+            "dir_relpath": "mobile/android/tests/browser/junit3/src",
+            "file_relpath": "mobile/android/tests/browser/junit3/src/TestDistribution.java",
+            "flavor": "instrumentation",
+            "here": "/Users/nalexander/Mozilla/gecko-dev/mobile/android/tests/browser/junit3",
+            "manifest": "/Users/nalexander/Mozilla/gecko-dev/mobile/android/tests/browser/junit3/instrumentation.ini",
+            "name": "src/TestDistribution.java",
+            "path": "/Users/nalexander/Mozilla/gecko-dev/mobile/android/tests/browser/junit3/src/TestDistribution.java",
+            "relpath": "src/TestDistribution.java",
+            "subsuite": "browser"
+        }
+    ],
+    "image/test/browser/browser_bug666317.js": [
+        {
+            "dir_relpath": "image/test/browser",
+            "file_relpath": "image/test/browser/browser_bug666317.js",
+            "flavor": "browser-chrome",
+            "here": "/home/chris/m-c/obj-dbg/_tests/testing/mochitest/browser/image/test/browser",
+            "manifest": "/home/chris/m-c/image/test/browser/browser.ini",
+            "name": "browser_bug666317.js",
+            "path": "/home/chris/m-c/obj-dbg/_tests/testing/mochitest/browser/image/test/browser/browser_bug666317.js",
+            "relpath": "image/test/browser/browser_bug666317.js",
+            "skip-if": "e10s # Bug 948194 - Decoded Images seem to not be discarded on memory-pressure notification with e10s enabled",
+            "subsuite": ""
+        }
+   ],
+   "devtools/client/markupview/test/browser_markupview_copy_image_data.js": [
+        {
+            "dir_relpath": "devtools/client/markupview/test",
+            "file_relpath": "devtools/client/markupview/test/browser_markupview_copy_image_data.js",
+            "flavor": "browser-chrome",
+            "here": "/home/chris/m-c/obj-dbg/_tests/testing/mochitest/browser/devtools/client/markupview/test",
+            "manifest": "/home/chris/m-c/devtools/client/markupview/test/browser.ini",
+            "name": "browser_markupview_copy_image_data.js",
+            "path": "/home/chris/m-c/obj-dbg/_tests/testing/mochitest/browser/devtools/client/markupview/test/browser_markupview_copy_image_data.js",
+            "relpath": "devtools/client/markupview/test/browser_markupview_copy_image_data.js",
+            "subsuite": "devtools",
+            "tags": "devtools"
+        }
+   ]
+}, {
+   "/Users/gps/src/firefox/toolkit/mozapps/update/test/unit/xpcshell_updater.ini": "\\ndata/**\\nxpcshell_updater.ini"
+}]'''.strip()
+
+
+class TestTestResolver(unittest.TestCase):
+    def setUp(self):
+        self._temp_files = []
+
+    def tearDown(self):
+        for f in self._temp_files:
+            del f
+
+        self._temp_files = []
+
+    def _get_test_metadata(self):
+        f = NamedTemporaryFile()
+        f.write(ALL_TESTS_JSON)
+        f.flush()
+        self._temp_files.append(f)
+
+        return TestResolver(filename=f.name)
+
+    def test_load(self):
+        t = self._get_test_metadata()
+        self.assertEqual(len(t._tests_by_path), 8)
+
+        self.assertEqual(len(list(t.tests_with_flavor('xpcshell'))), 3)
+        self.assertEqual(len(list(t.tests_with_flavor('mochitest-plain'))), 0)
+
+    def test_resolve_all(self):
+        t = self._get_test_metadata()
+        self.assertEqual(len(list(t.resolve())), 9)
+
+    def test_resolve_filter_flavor(self):
+        t = self._get_test_metadata()
+        self.assertEqual(len(list(t.resolve(flavor='xpcshell'))), 4)
+
+    def test_resolve_by_dir(self):
+        t = self._get_test_metadata()
+        self.assertEqual(len(list(t.resolve(paths=['services/common']))), 2)
+
+    def test_resolve_multiple_paths(self):
+        t = self._get_test_metadata()
+        result = list(t.resolve(paths=['services', 'toolkit']))
+        self.assertEqual(len(result), 4)
+
+    def test_resolve_support_files(self):
+        expected_support_files = "\ndata/**\nxpcshell_updater.ini"
+        t = self._get_test_metadata()
+        result = list(t.resolve(paths=['toolkit']))
+        self.assertEqual(len(result), 2)
+
+        for test in result:
+            self.assertEqual(test['support-files'],
+                             expected_support_files)
+
+    def test_resolve_path_prefix(self):
+        t = self._get_test_metadata()
+        result = list(t.resolve(paths=['image']))
+        self.assertEqual(len(result), 1)
+
+
+if __name__ == '__main__':
+    main()
--- a/testing/mochitest/mach_commands.py
+++ b/testing/mochitest/mach_commands.py
@@ -153,23 +153,23 @@ class MochitestRunner(MozbuildObject):
 
         self.tests_dir = os.path.join(self.topobjdir, '_tests')
         self.mochitest_dir = os.path.join(
             self.tests_dir,
             'testing',
             'mochitest')
         self.bin_dir = os.path.join(self.topobjdir, 'dist', 'bin')
 
-    def resolve_tests(self, test_paths, test_objects=None, cwd=None):
+    def resolve_tests(self, test_paths, test_objects=None, **kwargs):
         if test_objects:
             return test_objects
 
         from mozbuild.testing import TestResolver
         resolver = self._spawn(TestResolver)
-        tests = list(resolver.resolve_tests(paths=test_paths, cwd=cwd))
+        tests = list(resolver.resolve_tests(paths=test_paths, **kwargs))
         return tests
 
     def run_desktop_test(self, context, tests=None, suite=None, **kwargs):
         """Runs a mochitest.
 
         suite is the type of mochitest to run. It can be one of ('plain',
         'chrome', 'browser', 'a11y', 'jetpack-package', 'jetpack-addon').
         """
@@ -357,34 +357,35 @@ class MachCommands(MachCommandBase):
         self._ensure_state_subdir_exists('.')
 
         test_paths = kwargs['test_paths']
         kwargs['test_paths'] = []
 
         mochitest = self._spawn(MochitestRunner)
         tests = []
         if resolve_tests:
-            tests = mochitest.resolve_tests(test_paths, test_objects, cwd=self._mach_context.cwd)
+            tests = mochitest.resolve_tests(test_paths, test_objects, flavors=flavors)
 
         driver = self._spawn(BuildDriver)
         driver.install_tests(tests)
 
         subsuite = kwargs.get('subsuite')
         if subsuite == 'default':
             kwargs['subsuite'] = None
 
         suites = defaultdict(list)
         unsupported = set()
         for test in tests:
+            flavor = test.get('flavor')
             # Filter out non-mochitests and unsupported flavors.
-            if test['flavor'] not in ALL_FLAVORS:
+            if flavor not in ALL_FLAVORS:
                 continue
 
-            key = (test['flavor'], test.get('subsuite', ''))
-            if test['flavor'] not in flavors:
+            key = (flavor, test.get('subsuite', ''))
+            if flavor not in flavors:
                 unsupported.add(key)
                 continue
 
             if subsuite == 'default':
                 # "--subsuite default" means only run tests that don't have a subsuite
                 if test.get('subsuite'):
                     unsupported.add(key)
                     continue
@@ -494,18 +495,18 @@ class RobocopCommands(MachCommandBase):
         from mozbuild.controller.building import BuildDriver
         self._ensure_state_subdir_exists('.')
 
         test_paths = kwargs['test_paths']
         kwargs['test_paths'] = []
 
         from mozbuild.testing import TestResolver
         resolver = self._spawn(TestResolver)
-        tests = list(resolver.resolve_tests(paths=test_paths, cwd=self._mach_context.cwd,
-                                            flavor='instrumentation', subsuite='robocop'))
+        tests = list(resolver.resolve_tests(paths=test_paths, flavor='instrumentation',
+                                            subsuite='robocop'))
         driver = self._spawn(BuildDriver)
         driver.install_tests(tests)
 
         if len(tests) < 1:
             print(ROBOCOP_TESTS_NOT_FOUND.format('\n'.join(
                 sorted(list(test_paths)))))
             return 1