Bug 987414 - Pass multiple test arguments to mach testing commands. r=ahal, a=NPOTB
authorGregory Szorc <gps@mozilla.com>
Mon, 24 Mar 2014 16:19:57 -0700
changeset 191899 8d6733e63feda2e0859c979545f6a5f739cde650
parent 191898 e5d7e5709d98611429fc302befb90948494db043
child 191900 6680a86d1ebceec2ad44b22db53ee5be28f1ce3c
push id3503
push userraliiev@mozilla.com
push dateMon, 28 Apr 2014 18:51:11 +0000
treeherdermozilla-beta@c95ac01e332e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersahal, NPOTB
bugs987414
milestone30.0a2
Bug 987414 - Pass multiple test arguments to mach testing commands. r=ahal, a=NPOTB Previously, mach xpcshell-test and mochitest-* were limited to a single test "path" argument. This patch enables multiple arguments to be passed in. TestResolver in the build system has gained the ability to process multiple paths in a single invocation. The mach commands have been modified to utilize this new feature. Only mach code paths that pass manifestdestiny.TestManifest instances into the test runner can accept multiple arguments. This is because there is no other way to pass a custom set of tests into the test runner. If multiple test arguments are used but not supported, a warning is emitted.
python/mozbuild/mozbuild/test/test_testing.py
python/mozbuild/mozbuild/testing.py
testing/mochitest/mach_commands.py
testing/xpcshell/mach_commands.py
--- a/python/mozbuild/mozbuild/test/test_testing.py
+++ b/python/mozbuild/mozbuild/test/test_testing.py
@@ -137,25 +137,30 @@ class TestTestMetadata(Base):
         self.assertEqual(len(list(t.resolve_tests())), 5)
 
     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('services/common'))), 2)
+        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)
+
 
 class TestTestResolver(Base):
     FAKE_TOPSRCDIR = '/Users/gps/src/firefox'
 
     def setUp(self):
         Base.setUp(self)
 
         self._temp_dirs = []
@@ -178,36 +183,36 @@ class TestTestResolver(Base):
         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(path='common', cwd=os.path.join(r.topsrcdir,
+        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(path='services'))
-        actual = list(r.resolve_tests(path='services', cwd='/'))
+        expected = list(r.resolve_tests(paths=['services']))
+        actual = list(r.resolve_tests(paths=['services'], cwd='/'))
         self.assertEqual(actual, expected)
 
-        actual = list(r.resolve_tests(path='services', cwd=r.topsrcdir))
+        actual = list(r.resolve_tests(paths=['services'], cwd=r.topsrcdir))
         self.assertEqual(actual, expected)
 
-        actual = list(r.resolve_tests(path='services', cwd=r.topobjdir))
+        actual = list(r.resolve_tests(paths=['services'], cwd=r.topobjdir))
         self.assertEqual(actual, expected)
 
 
 if __name__ == '__main__':
     main()
--- a/python/mozbuild/mozbuild/testing.py
+++ b/python/mozbuild/mozbuild/testing.py
@@ -64,27 +64,26 @@ class TestMetadata(object):
         """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, path=None, flavor=None, under_path=None):
+    def resolve_tests(self, paths=None, flavor=None, under_path=None):
         """Resolve tests from an identifier.
 
         This is a generator of dicts describing each test.
 
-        If ``path`` is a known test file, the tests associated with that file
-        are returned. Files can be specified by full path (relative to main
-        directory), or as a file fragment. The lookup simply tests whether
-        the string is in the path of a test file.
-
-        If ``path`` is a directory, the tests in that directory are returned.
+        ``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, all tests in that directory are returned. If
+        the string appears in a known test file, that test file is considered.
 
         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``.
@@ -96,40 +95,43 @@ class TestMetadata(object):
 
                 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)
 
-        path = mozpath.normpath(path) if path else None
+        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 path in self._test_dirs:
-            candidates = []
-            for p, tests in sorted(self._tests_by_path.items()):
-                if not p.startswith(path):
-                    continue
+            # If the path is a directory, pull in all tests in that directory.
+            if path in self._test_dirs:
+                candidate_paths |= {p for p in self._tests_by_path
+                                    if p.startswith(path)}
+                continue
 
-                candidates.extend(tests)
+            # 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 test in fltr(candidates):
+        for p in sorted(candidate_paths):
+            tests = self._tests_by_path[p]
+
+            for test in fltr(tests):
                 yield test
 
-            return
-
-        # Do file lookup.
-        candidates = []
-        for p, tests in sorted(self._tests_by_path.items()):
-            if path is None or path in p:
-                candidates.extend(tests)
-
-        for test in fltr(candidates):
-            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)
 
         self._tests = TestMetadata(filename=os.path.join(self.topobjdir,
--- a/testing/mochitest/mach_commands.py
+++ b/testing/mochitest/mach_commands.py
@@ -94,29 +94,31 @@ class MochitestRunner(MozbuildObject):
         build_path = os.path.join(self.topobjdir, 'build')
         if build_path not in sys.path:
             sys.path.append(build_path)
 
         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 run_b2g_test(self, test_file=None, b2g_home=None, xre_path=None,
+    def run_b2g_test(self, test_paths=None, b2g_home=None, xre_path=None,
                      total_chunks=None, this_chunk=None, no_window=None,
                      **kwargs):
         """Runs a b2g mochitest.
 
-        test_file is a path to a test file. It can be a relative path from the
-        top source directory, an absolute filename, or a directory containing
-        test files.
+        test_paths is an enumerable of paths to tests. It can be a relative path
+        from the top source directory, an absolute filename, or a directory
+        containing test files.
         """
         # Need to call relpath before os.chdir() below.
         test_path = ''
-        if test_file:
-            test_path = self._wrap_path_argument(test_file).relpath()
+        if test_paths:
+            if len(test_paths) > 1:
+                print('Warning: Only the first test path will be used.')
+            test_path = self._wrap_path_argument(test_paths[0]).relpath()
 
         # TODO without os.chdir, chained imports fail below
         os.chdir(self.mochitest_dir)
 
         # The imp module can spew warnings if the modules below have
         # already been imported, ignore them.
         with warnings.catch_warnings():
             warnings.simplefilter('ignore')
@@ -193,27 +195,27 @@ class MochitestRunner(MozbuildObject):
             return 1
 
         options.b2gPath = b2g_home
         options.logcat_dir = self.mochitest_dir
         options.httpdPath = self.mochitest_dir
         options.xrePath = xre_path
         return mochitest.run_remote_mochitests(parser, options)
 
-    def run_desktop_test(self, context, suite=None, test_file=None, debugger=None,
+    def run_desktop_test(self, context, suite=None, test_paths=None, debugger=None,
         debugger_args=None, slowscript=False, shuffle=False, keep_open=False,
         rerun_failures=False, no_autorun=False, repeat=0, run_until_failure=False,
         slow=False, chunk_by_dir=0, total_chunks=None, this_chunk=None,
         jsdebugger=False, debug_on_failure=False, start_at=None, end_at=None,
         e10s=False, dmd=False, dump_output_directory=None,
         dump_about_memory_after_test=False, dump_dmd_after_test=False,
         install_extension=None, **kwargs):
         """Runs a mochitest.
 
-        test_file is a path to a test file. It can be a relative path from the
+        test_paths are path to tests. They can be a relative path from the
         top source directory, an absolute filename, or a directory containing
         test files.
 
         suite is the type of mochitest to run. It can be one of ('plain',
         'chrome', 'browser', 'metro', 'a11y').
 
         debugger is a program name or path to a binary (presumably a debugger)
         to run the test in. e.g. 'gdb'
@@ -223,24 +225,23 @@ class MochitestRunner(MozbuildObject):
         slowscript is true if the user has requested the SIGSEGV mechanism of
         invoking the slow script dialog.
 
         shuffle is whether test order should be shuffled (defaults to false).
 
         keep_open denotes whether to keep the browser open after tests
         complete.
         """
-        if rerun_failures and test_file:
+        if rerun_failures and test_paths:
             print('Cannot specify both --rerun-failures and a test path.')
             return 1
 
         # Need to call relpath before os.chdir() below.
-        test_path = ''
-        if test_file:
-            test_path = self._wrap_path_argument(test_file).relpath()
+        if test_paths:
+            test_paths = [self._wrap_path_argument(p).relpath() for p in test_paths]
 
         failure_file_path = os.path.join(self.statedir, 'mochitest_failures.json')
 
         if rerun_failures and not os.path.exists(failure_file_path):
             print('No failure file present. Did you run mochitests before?')
             return 1
 
         from StringIO import StringIO
@@ -327,20 +328,20 @@ class MochitestRunner(MozbuildObject):
 
         options.failureFile = failure_file_path
         if install_extension != None:
             options.extensionsToInstall = [os.path.join(self.topsrcdir,install_extension)]
 
         for k, v in kwargs.iteritems():
             setattr(options, k, v)
 
-        if test_path:
+        if test_paths:
             resolver = self._spawn(TestResolver)
 
-            tests = list(resolver.resolve_tests(path=test_path, flavor=flavor,
+            tests = list(resolver.resolve_tests(paths=test_paths, flavor=flavor,
                 cwd=context.cwd))
 
             if not tests:
                 print('No tests could be found in the path specified. Please '
                     'specify a path that is a test file or is a directory '
                     'containing tests.')
                 return 1
 
@@ -500,17 +501,17 @@ def MochitestCommand(func):
     dumpDMD = CommandArgument('--dump-dmd-after-test', action='store_true',
         help='Dump a DMD log after every test.')
     func = dumpDMD(func)
 
     dumpOutputDirectory = CommandArgument('--dump-output-directory', action='store',
         help='Specifies the directory in which to place dumped memory reports.')
     func = dumpOutputDirectory(func)
 
-    path = CommandArgument('test_file', default=None, nargs='?',
+    path = CommandArgument('test_paths', default=None, nargs='*',
         metavar='TEST',
         help='Test to run. Can be specified as a single file, a ' \
             'directory, or omitted. If omitted, the entire test suite is ' \
             'executed.')
     func = path(func)
 
     install_extension = CommandArgument('--install-extension',
         help='Install given extension before running selected tests. ' \
@@ -563,90 +564,90 @@ def B2GCommand(func):
     this_chunk = CommandArgument('--this-chunk', type=int,
         help='If running tests by chunks, the number of the chunk to run.')
     func = this_chunk(func)
 
     hide_subtests = CommandArgument('--hide-subtests', action='store_true',
         help='If specified, will only log subtest results on failure or timeout.')
     func = hide_subtests(func)
 
-    path = CommandArgument('test_file', default=None, nargs='?',
+    path = CommandArgument('test_paths', default=None, nargs='*',
         metavar='TEST',
         help='Test to run. Can be specified as a single file, a ' \
             'directory, or omitted. If omitted, the entire test suite is ' \
             'executed.')
     func = path(func)
 
     return func
 
 
 
 @CommandProvider
 class MachCommands(MachCommandBase):
     @Command('mochitest-plain', category='testing',
         conditions=[conditions.is_firefox],
         description='Run a plain mochitest.')
     @MochitestCommand
-    def run_mochitest_plain(self, test_file, **kwargs):
-        return self.run_mochitest(test_file, 'plain', **kwargs)
+    def run_mochitest_plain(self, test_paths, **kwargs):
+        return self.run_mochitest(test_paths, 'plain', **kwargs)
 
     @Command('mochitest-chrome', category='testing',
         conditions=[conditions.is_firefox],
         description='Run a chrome mochitest.')
     @MochitestCommand
-    def run_mochitest_chrome(self, test_file, **kwargs):
-        return self.run_mochitest(test_file, 'chrome', **kwargs)
+    def run_mochitest_chrome(self, test_paths, **kwargs):
+        return self.run_mochitest(test_paths, 'chrome', **kwargs)
 
     @Command('mochitest-browser', category='testing',
         conditions=[conditions.is_firefox],
         description='Run a mochitest with browser chrome.')
     @MochitestCommand
-    def run_mochitest_browser(self, test_file, **kwargs):
-        return self.run_mochitest(test_file, 'browser', **kwargs)
+    def run_mochitest_browser(self, test_paths, **kwargs):
+        return self.run_mochitest(test_paths, 'browser', **kwargs)
 
     @Command('mochitest-metro', category='testing',
         conditions=[conditions.is_firefox],
         description='Run a mochitest with metro browser chrome.')
     @MochitestCommand
-    def run_mochitest_metro(self, test_file, **kwargs):
-        return self.run_mochitest(test_file, 'metro', **kwargs)
+    def run_mochitest_metro(self, test_paths, **kwargs):
+        return self.run_mochitest(test_paths, 'metro', **kwargs)
 
     @Command('mochitest-a11y', category='testing',
         conditions=[conditions.is_firefox],
         description='Run an a11y mochitest.')
     @MochitestCommand
-    def run_mochitest_a11y(self, test_file, **kwargs):
-        return self.run_mochitest(test_file, 'a11y', **kwargs)
+    def run_mochitest_a11y(self, test_paths, **kwargs):
+        return self.run_mochitest(test_paths, 'a11y', **kwargs)
 
     @Command('webapprt-test-chrome', category='testing',
         conditions=[conditions.is_firefox],
         description='Run a webapprt chrome mochitest.')
     @MochitestCommand
-    def run_mochitest_webapprt_chrome(self, test_file, **kwargs):
-        return self.run_mochitest(test_file, 'webapprt-chrome', **kwargs)
+    def run_mochitest_webapprt_chrome(self, test_paths, **kwargs):
+        return self.run_mochitest(test_paths, 'webapprt-chrome', **kwargs)
 
     @Command('webapprt-test-content', category='testing',
         conditions=[conditions.is_firefox],
         description='Run a webapprt content mochitest.')
     @MochitestCommand
-    def run_mochitest_webapprt_content(self, test_file, **kwargs):
-        return self.run_mochitest(test_file, 'webapprt-content', **kwargs)
+    def run_mochitest_webapprt_content(self, test_paths, **kwargs):
+        return self.run_mochitest(test_paths, 'webapprt-content', **kwargs)
 
-    def run_mochitest(self, test_file, flavor, **kwargs):
+    def run_mochitest(self, test_paths, flavor, **kwargs):
         from mozbuild.controller.building import BuildDriver
 
         self._ensure_state_subdir_exists('.')
 
         driver = self._spawn(BuildDriver)
         driver.install_tests(remove=False)
 
         mochitest = self._spawn(MochitestRunner)
 
         return mochitest.run_desktop_test(self._mach_context,
-            test_file=test_file, suite=flavor, **kwargs)
+            test_paths=test_paths, suite=flavor, **kwargs)
 
 
 # TODO For now b2g commands will only work with the emulator,
 # they should be modified to work with all devices.
 def is_emulator(cls):
     """Emulator needs to be configured."""
     return cls.device_name in ('emulator', 'emulator-jb')
 
@@ -662,34 +663,34 @@ class B2GCommands(MachCommandBase):
 
         for attr in ('b2g_home', 'xre_path', 'device_name'):
             setattr(self, attr, getattr(context, attr, None))
 
     @Command('mochitest-remote', category='testing',
         description='Run a remote mochitest.',
         conditions=[conditions.is_b2g, is_emulator])
     @B2GCommand
-    def run_mochitest_remote(self, test_file, **kwargs):
+    def run_mochitest_remote(self, test_paths, **kwargs):
         from mozbuild.controller.building import BuildDriver
 
         self._ensure_state_subdir_exists('.')
 
         driver = self._spawn(BuildDriver)
         driver.install_tests(remove=False)
 
         mochitest = self._spawn(MochitestRunner)
         return mochitest.run_b2g_test(b2g_home=self.b2g_home,
-                xre_path=self.xre_path, test_file=test_file, **kwargs)
+                xre_path=self.xre_path, test_paths=test_paths, **kwargs)
 
     @Command('mochitest-b2g-desktop', category='testing',
         conditions=[conditions.is_b2g_desktop],
         description='Run a b2g desktop mochitest.')
     @B2GCommand
-    def run_mochitest_b2g_desktop(self, test_file, **kwargs):
+    def run_mochitest_b2g_desktop(self, test_paths, **kwargs):
         from mozbuild.controller.building import BuildDriver
 
         self._ensure_state_subdir_exists('.')
 
         driver = self._spawn(BuildDriver)
         driver.install_tests(remove=False)
 
         mochitest = self._spawn(MochitestRunner)
-        return mochitest.run_b2g_test(test_file=test_file, **kwargs)
+        return mochitest.run_b2g_test(test_paths=test_paths, **kwargs)
--- a/testing/xpcshell/mach_commands.py
+++ b/testing/xpcshell/mach_commands.py
@@ -57,41 +57,41 @@ class XPCShellRunner(MozbuildObject):
     """Run xpcshell tests."""
     def run_suite(self, **kwargs):
         from manifestparser import TestManifest
         manifest = TestManifest(manifests=[os.path.join(self.topobjdir,
             '_tests', 'xpcshell', 'xpcshell.ini')])
 
         return self._run_xpcshell_harness(manifest=manifest, **kwargs)
 
-    def run_test(self, test_file, interactive=False,
+    def run_test(self, test_paths, interactive=False,
                  keep_going=False, sequential=False, shuffle=False,
                  debugger=None, debuggerArgs=None, debuggerInteractive=None,
                  rerun_failures=False,
                  # ignore parameters from other platforms' options
                  **kwargs):
         """Runs an individual xpcshell test."""
         from mozbuild.testing import TestResolver
         from manifestparser import TestManifest
 
         # TODO Bug 794506 remove once mach integrates with virtualenv.
         build_path = os.path.join(self.topobjdir, 'build')
         if build_path not in sys.path:
             sys.path.append(build_path)
 
-        if test_file == 'all':
+        if test_paths == ['all']:
             self.run_suite(interactive=interactive,
                            keep_going=keep_going, shuffle=shuffle, sequential=sequential,
                            debugger=debugger, debuggerArgs=debuggerArgs,
                            debuggerInteractive=debuggerInteractive,
                            rerun_failures=rerun_failures)
             return
 
         resolver = self._spawn(TestResolver)
-        tests = list(resolver.resolve_tests(path=test_file, flavor='xpcshell',
+        tests = list(resolver.resolve_tests(paths=test_paths, flavor='xpcshell',
             cwd=self.cwd))
 
         if not tests:
             raise InvalidTestPathError('We could not find an xpcshell test '
                 'for the passed test path. Please select a path that is '
                 'a test file or is a directory containing xpcshell tests.')
 
         # Dynamically write out a manifest holding all the discovered tests.
@@ -213,17 +213,17 @@ class AndroidXPCShellRunner(MozbuildObje
             if ip:
                 dm = devicemanagerSUT.DeviceManagerSUT(ip, port, deviceRoot=remote_test_root)
             else:
                 raise Exception("You must provide a device IP to connect to via the --ip option")
         return dm
 
     """Run Android xpcshell tests."""
     def run_test(self,
-                 test_file, keep_going,
+                 test_paths, keep_going,
                  devicemanager, ip, port, remote_test_root, no_setup, local_apk,
                  # ignore parameters from other platforms' options
                  **kwargs):
         # TODO Bug 794506 remove once mach integrates with virtualenv.
         build_path = os.path.join(self.topobjdir, 'build')
         if build_path not in sys.path:
             sys.path.append(build_path)
 
@@ -253,23 +253,25 @@ class AndroidXPCShellRunner(MozbuildObje
                 if file.endswith(".apk") and file.startswith("fennec"):
                     options.localAPK = os.path.join(options.objdir, "dist")
                     options.localAPK = os.path.join(options.localAPK, file)
                     print ("using APK: " + options.localAPK)
                     break
             else:
                 raise Exception("You must specify an APK")
 
-        if test_file == 'all':
+        if test_paths == ['all']:
             testdirs = []
             options.testPath = None
             options.verbose = False
         else:
-            testdirs = test_file
-            options.testPath = test_file
+            if len(test_paths) > 1:
+                print('Warning: only the first test path argument will be used.')
+            testdirs = test_paths[0]
+            options.testPath = test_paths[0]
             options.verbose = True
         dummy_log = StringIO()
         xpcshell = remotexpcshelltests.XPCShellRemote(dm, options, args=testdirs, log=dummy_log)
         self.log_manager.enable_unstructured()
 
         xpcshell_filter = TestStartFilter()
         self.log_manager.terminal_handler.addFilter(xpcshell_filter)
 
@@ -317,30 +319,33 @@ class B2GXPCShellRunner(MozbuildObject):
             print('There was a problem downloading busybox. Proceeding without it,' \
                   'initial setup will be slow.')
             return
 
         with open(busybox_path, 'wb') as f:
             f.write(data.read())
         return busybox_path
 
-    def run_test(self, test_file, b2g_home=None, busybox=None,
+    def run_test(self, test_paths, b2g_home=None, busybox=None,
                  # ignore parameters from other platforms' options
                  **kwargs):
         try:
             import which
             which.which('adb')
         except which.WhichError:
             # TODO Find adb automatically if it isn't on the path
             print(ADB_NOT_FOUND % ('mochitest-remote', b2g_home))
             sys.exit(1)
 
         test_path = None
-        if test_file:
-            test_path = self._wrap_path_argument(test_file).relpath()
+        if test_paths:
+            if len(test_paths) > 1:
+                print('Warning: Only the first test path will be used.')
+
+            test_path = self._wrap_path_argument(test_paths[0]).relpath()
 
         import runtestsb2g
         parser = runtestsb2g.B2GOptions()
         options, args = parser.parse_args([])
 
         options.b2g_path = b2g_home
         options.busybox = busybox or os.environ.get('BUSYBOX')
         options.emulator = 'arm'
@@ -373,17 +378,17 @@ class MachCommands(MachCommandBase):
         MachCommandBase.__init__(self, context)
 
         for attr in ('b2g_home', 'device_name'):
             setattr(self, attr, getattr(context, attr, None))
 
     @Command('xpcshell-test', category='testing',
         conditions=[is_platform_supported],
         description='Run XPCOM Shell tests.')
-    @CommandArgument('test_file', default='all', nargs='?', metavar='TEST',
+    @CommandArgument('test_paths', default='all', nargs='*', metavar='TEST',
         help='Test to run. Can be specified as a single JS file, a directory, '
              'or omitted. If omitted, the entire test suite is executed.')
     @CommandArgument("--debugger", default=None, metavar='DEBUGGER',
                      help = "Run xpcshell under the given debugger.")
     @CommandArgument("--debugger-args", default=None, metavar='ARGS', type=str,
                      dest = "debuggerArgs",
                      help = "pass the given args to the debugger _before_ "
                             "the application on the command line")