commit 425f9ec4676cda1d3ffc49bb86adb9d94aa63380
parent 3884dcda47f1a71125ee101416a5901f8dfd8740
Author: Nika Layzell <nika@thelayzells.com>
Date: Tue, 16 Dec 2025 04:53:45 +0000
Bug 1908693 - Part 7: Basic support for mochitest-plain on iOS, r=gbrown,geckoview-reviewers,owlish,mozbase-reviewers,jmaher
This patch centers around a hacked up version of runtestsremote.py which
instead uses the helper types from mozdevice.ios (added in the previous part)
to run mochitests on an iOS device. With this change it is possible to run some
basic mochitests on simulator from a local build. There is currently no support
for running on CI.
Tests involving any form of more advanced features such as opening pop-up
windows still are not supported, as the testing browser doesn't support tabs
yet.
Unfortunately, currently a lot of code is duplicated between runtestsremote and
runtestsremoteios. In the future, it might be worthwhile to merge these files
back together, and use a common abstraction instead of having separate
implementations for both platforms.
Differential Revision: https://phabricator.services.mozilla.com/D217138
Diffstat:
4 files changed, 574 insertions(+), 4 deletions(-)
diff --git a/testing/mochitest/mach_commands.py b/testing/mochitest/mach_commands.py
@@ -51,7 +51,7 @@ test path(s):
Please check spelling and make sure there are mochitests living there.
""".lstrip()
-SUPPORTED_APPS = ["firefox", "android", "thunderbird"]
+SUPPORTED_APPS = ["firefox", "android", "thunderbird", "ios"]
parser = None
@@ -157,6 +157,27 @@ class MochitestRunner(MozbuildObject):
return runtestsremote.run_test_harness(parser, options)
+ def run_ios_test(self, command_context, tests, **kwargs):
+ host_ret = verify_host_bin()
+ if host_ret != 0:
+ return host_ret
+
+ path = os.path.join(self.mochitest_dir, "runtestsremoteios.py")
+ load_source("runtestsremoteios", path)
+
+ import runtestsremoteios
+
+ options = Namespace(**kwargs)
+
+ from manifestparser import TestManifest
+
+ if tests and not options.manifestFile:
+ manifest = TestManifest()
+ manifest.tests.extend(tests)
+ options.manifestFile = manifest
+
+ return runtestsremoteios.run_test_harness(parser, options)
+
def run_geckoview_junit_test(self, context, **kwargs):
host_ret = verify_host_bin()
if host_ret != 0:
@@ -205,6 +226,13 @@ def setup_argument_parser():
# verify device and xre
verify_android_device(build_obj, install=InstallIntent.NO, xre=True)
+ if conditions.is_ios(build_obj):
+ # On iOS, check for a connected device before running tests.
+ from mozrunner.devices.ios_device import verify_ios_device
+
+ # verify device and xre
+ verify_ios_device(build_obj, install=False, xre=True)
+
global parser
parser = MochitestArgumentParser()
return parser
@@ -473,6 +501,18 @@ def run_mochitest_general(
kwargs["adbPath"] = get_adb_path(command_context)
run_mochitest = mochitest.run_android_test
+ elif buildapp == "ios":
+ from mozrunner.devices.ios_device import verify_ios_device
+
+ app = kwargs.get("app")
+ if not app:
+ app = "org.mozilla.ios.GeckoTestBrowser"
+ install = not kwargs.get("no_install")
+
+ # verify installation
+ verify_ios_device(command_context, install=install, xre=False, app=app)
+
+ run_mochitest = mochitest.run_ios_test
else:
run_mochitest = mochitest.run_desktop_test
diff --git a/testing/mochitest/mochitest_options.py b/testing/mochitest/mochitest_options.py
@@ -34,7 +34,7 @@ ALL_FLAVORS = {
"mochitest": {
"suite": "plain",
"aliases": ("plain", "mochitest"),
- "enabled_apps": ("firefox", "android"),
+ "enabled_apps": ("firefox", "android", "ios"),
"extra_args": {
"flavor": "plain",
},
@@ -997,8 +997,8 @@ class MochitestArguments(ArgumentContainer):
def validate(self, parser, options, context):
"""Validate generic options."""
- # and android doesn't use 'app' the same way, so skip validation
- if parser.app != "android":
+ # and android/iOS doesn't use 'app' the same way, so skip validation
+ if parser.app not in ("android", "ios"):
if options.app is None:
if build_obj:
from mozbuild.base import BinaryNotFoundException
@@ -1431,9 +1431,112 @@ class AndroidArguments(ArgumentContainer):
return options
+class IosArguments(ArgumentContainer):
+ """Ios specific arguments."""
+
+ args = [
+ [
+ ["--no-install"],
+ {
+ "action": "store_true",
+ "default": False,
+ "help": "Skip the installation of the app.",
+ },
+ ],
+ # FIXME: Support something like --deviceSerial.
+ [
+ ["--remote-webserver"],
+ {
+ "dest": "remoteWebServer",
+ "default": None,
+ "help": "IP address of the remote web server.",
+ },
+ ],
+ [
+ ["--http-port"],
+ {
+ "dest": "httpPort",
+ "default": DEFAULT_PORTS["http"],
+ "help": "http port of the remote web server.",
+ "suppress": True,
+ },
+ ],
+ [
+ ["--ssl-port"],
+ {
+ "dest": "sslPort",
+ "default": DEFAULT_PORTS["https"],
+ "help": "ssl port of the remote web server.",
+ "suppress": True,
+ },
+ ],
+ [
+ ["--remoteTestRoot"],
+ {
+ "dest": "remoteTestRoot",
+ "default": None,
+ "help": "Remote directory to use as test root "
+ "(eg. /data/local/tmp/test_root).",
+ "suppress": True,
+ },
+ ],
+ ]
+
+ defaults = {
+ # we don't want to exclude specialpowers on iOS just yet
+ "extensionsToExclude": [],
+ # mochijar doesn't get installed via marionette on iOS
+ "extensionsToInstall": [os.path.join(here, "mochijar")],
+ "logFile": "mochitest.log",
+ "utilityPath": None,
+ }
+
+ def validate(self, parser, options, context):
+ """Validate iOS options."""
+
+ if build_obj:
+ options.log_mach = "-"
+
+ objdir_xpi_stage = os.path.join(build_obj.distdir, "xpi-stage")
+ if os.path.isdir(objdir_xpi_stage):
+ options.extensionsToInstall = [
+ os.path.join(objdir_xpi_stage, "mochijar"),
+ os.path.join(objdir_xpi_stage, "specialpowers"),
+ ]
+
+ if options.remoteWebServer is None:
+ options.remoteWebServer = moznetwork.get_ip()
+
+ options.webServer = options.remoteWebServer
+
+ if options.app is None:
+ options.app = "org.mozilla.ios.GeckoTestBrowser"
+
+ if build_obj and "MOZ_HOST_BIN" in os.environ:
+ options.xrePath = os.environ["MOZ_HOST_BIN"]
+
+ # Only reset the xrePath if it wasn't provided
+ if options.xrePath is None:
+ options.xrePath = options.utilityPath
+
+ if build_obj:
+ options.topsrcdir = build_obj.topsrcdir
+
+ if options.pidFile != "":
+ f = open(options.pidFile, "w")
+ f.write("%s" % os.getpid())
+ f.close()
+
+ # allow us to keep original application around for cleanup while
+ # running tests
+ options.remoteappname = options.app
+ return options
+
+
container_map = {
"generic": [MochitestArguments],
"android": [MochitestArguments, AndroidArguments],
+ "ios": [MochitestArguments, IosArguments],
}
@@ -1476,6 +1579,8 @@ class MochitestArgumentParser(ArgumentParser):
if not self.app and build_obj:
if conditions.is_android(build_obj):
self.app = "android"
+ if conditions.is_ios(build_obj):
+ self.app = "ios"
if not self.app:
# platform can't be determined and app wasn't specified explicitly,
# so just use generic arguments and hope for the best
diff --git a/testing/mochitest/moz.build b/testing/mochitest/moz.build
@@ -117,6 +117,7 @@ TEST_HARNESS_FILES.testing.mochitest += [
"runjunit.py",
"runtests.py",
"runtestsremote.py",
+ "runtestsremoteios.py",
"server.js",
"start_desktop.js",
]
diff --git a/testing/mochitest/runtestsremoteios.py b/testing/mochitest/runtestsremoteios.py
@@ -0,0 +1,424 @@
+# 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 os
+import posixpath
+import shutil
+import sys
+import tempfile
+import traceback
+import uuid
+
+sys.path.insert(0, os.path.abspath(os.path.realpath(os.path.dirname(__file__))))
+
+import mozcrash
+import mozinfo
+from mochitest_options import MochitestArgumentParser, build_obj
+from mozdevice.ios import IosDevice
+from runtests import MessageLogger, MochitestDesktop
+
+SCRIPT_DIR = os.path.abspath(os.path.realpath(os.path.dirname(__file__)))
+
+
+class MochiRemoteIos(MochitestDesktop):
+ localProfile = None
+ logMessages = []
+
+ def __init__(self, options):
+ MochitestDesktop.__init__(self, options.flavor, vars(options))
+
+ # Fixme - support non-simulator devices.
+ self.isSimulator = True
+
+ if hasattr(options, "log"):
+ delattr(options, "log")
+
+ self.certdbNew = True
+ self.chromePushed = False
+
+ self.device = IosDevice.select_device(self.isSimulator)
+
+ if options.remoteTestRoot is None:
+ options.remoteTestRoot = self.device.test_root(options.remoteappname)
+ options.dumpOutputDirectory = options.remoteTestRoot
+
+ self.remoteLogFile = posixpath.join(
+ options.remoteTestRoot, "logs", "mochitest.log"
+ )
+ logParent = posixpath.dirname(self.remoteLogFile)
+ self.device.rm(logParent, recursive=True)
+ self.device.mkdir(logParent, parents=True)
+
+ self.remoteProfile = posixpath.join(options.remoteTestRoot, "profile")
+ self.device.rm(self.remoteProfile, force=True, recursive=True)
+
+ self.message_logger = MessageLogger(logger=None)
+ self.message_logger.logger = self.log
+
+ # FIXME: There doesn't appear to be a way to check if an app is
+ # installed on an iOS device?
+
+ self.remoteModulesDir = posixpath.join(options.remoteTestRoot, "modules/")
+
+ self.remoteCache = posixpath.join(options.remoteTestRoot, "cache/")
+ self.device.rm(self.remoteCache, force=True, recursive=True)
+
+ # move necko cache to a location that can be cleaned up
+ options.extraPrefs += [
+ f"browser.cache.disk.parent_directory={self.remoteCache}"
+ ]
+
+ self.remoteMozLog = posixpath.join(options.remoteTestRoot, "mozlog")
+ self.device.rm(self.remoteMozLog, force=True, recursive=True)
+ self.device.mkdir(self.remoteMozLog, parents=True)
+
+ self.remoteChromeTestDir = posixpath.join(options.remoteTestRoot, "chrome")
+ self.device.rm(self.remoteChromeTestDir, force=True, recursive=True)
+ self.device.mkdir(self.remoteChromeTestDir, parents=True)
+
+ self.appName = options.remoteappname
+ self.device.stop_application(self.appName)
+
+ mozinfo.info["is_emulator"] = self.isSimulator
+
+ def cleanup(self, options, final=False):
+ if final:
+ self.device.rm(self.remoteChromeTestDir, force=True, recursive=True)
+ self.chromePushed = False
+ uploadDir = os.environ.get("MOZ_UPLOAD_DIR", None)
+ if uploadDir and self.device.is_dir(self.remoteMozLog):
+ self.device.pull(self.remoteMozLog, uploadDir)
+ self.device.rm(self.remoteLogFile, force=True)
+ self.device.rm(self.remoteProfile, force=True, recursive=True)
+ self.device.rm(self.remoteCache, force=True, recursive=True)
+ MochitestDesktop.cleanup(self, options, final)
+ self.localProfile = None
+
+ def dumpScreen(self, utilityPath):
+ self.log.info("Would take a screenshot, but not implemented for iOS")
+
+ def findPath(self, paths, filename=None):
+ for path in paths:
+ p = path
+ if filename:
+ p = os.path.join(p, filename)
+ if os.path.exists(self.getFullPath(p)):
+ return path
+ return None
+
+ # This seems kludgy, but this class uses paths from the remote host in the
+ # options, except when calling up to the base class, which doesn't
+ # understand the distinction. This switches out the remote values for local
+ # ones that the base class understands. This is necessary for the web
+ # server, SSL tunnel and profile building functions.
+ def switchToLocalPaths(self, options):
+ """Set local paths in the options, return a function that will restore remote values"""
+ remoteXrePath = options.xrePath
+ remoteProfilePath = options.profilePath
+ remoteUtilityPath = options.utilityPath
+
+ paths = [
+ options.xrePath,
+ ]
+ if build_obj:
+ paths.append(os.path.join(build_obj.topobjdir, "dist", "bin"))
+ options.xrePath = self.findPath(paths)
+ if options.xrePath is None:
+ self.log.error(
+ f"unable to find xulrunner path for {os.name}, please specify with --xre-path"
+ )
+ sys.exit(1)
+
+ xpcshell = "xpcshell"
+ if os.name == "nt":
+ xpcshell += ".exe"
+
+ if options.utilityPath:
+ paths = [options.utilityPath, options.xrePath]
+ else:
+ paths = [options.xrePath]
+ options.utilityPath = self.findPath(paths, xpcshell)
+
+ if options.utilityPath is None:
+ self.log.error(
+ f"unable to find utility path for {os.name}, please specify with --utility-path"
+ )
+ sys.exit(1)
+
+ if self.localProfile:
+ options.profilePath = self.localProfile
+ else:
+ options.profilePath = None
+
+ def fixup():
+ options.xrePath = remoteXrePath
+ options.utilityPath = remoteUtilityPath
+ options.profilePath = remoteProfilePath
+
+ return fixup
+
+ def startServers(self, options, debuggerInfo, public=None):
+ """Create the servers on the host and start them up"""
+ restoreRemotePaths = self.switchToLocalPaths(options)
+ MochitestDesktop.startServers(self, options, debuggerInfo, public=True)
+ restoreRemotePaths()
+
+ def buildProfile(self, options):
+ restoreRemotePaths = self.switchToLocalPaths(options)
+ if options.testingModulesDir:
+ try:
+ self.device.push(options.testingModulesDir, self.remoteModulesDir)
+ self.device.chmod(self.remoteModulesDir, recursive=True)
+ except Exception:
+ self.log.error(
+ "Automation Error: Unable to copy test modules to device."
+ )
+ raise
+ savedTestingModulesDir = options.testingModulesDir
+ options.testingModulesDir = self.remoteModulesDir
+ else:
+ savedTestingModulesDir = None
+ manifest = MochitestDesktop.buildProfile(self, options)
+ if savedTestingModulesDir:
+ options.testingModulesDir = savedTestingModulesDir
+ self.localProfile = options.profilePath
+
+ restoreRemotePaths()
+ options.profilePath = self.remoteProfile
+ return manifest
+
+ def buildURLOptions(self, options, env):
+ saveLogFile = options.logFile
+ options.logFile = self.remoteLogFile
+ options.profilePath = self.localProfile
+ env["MOZ_HIDE_RESULTS_TABLE"] = "1"
+ retVal = MochitestDesktop.buildURLOptions(self, options, env)
+
+ # we really need testConfig.js (for browser chrome)
+ try:
+ self.device.push(options.profilePath, self.remoteProfile)
+ self.device.chmod(self.remoteProfile, recursive=True)
+ except Exception:
+ self.log.error("Automation Error: Unable to copy profile to device.")
+ raise
+
+ options.profilePath = self.remoteProfile
+ options.logFile = saveLogFile
+ return retVal
+
+ def getChromeTestDir(self, options):
+ local = super().getChromeTestDir(options)
+ remote = self.remoteChromeTestDir
+ if options.flavor == "chrome" and not self.chromePushed:
+ self.log.info(f"pushing {local} to {remote} on device...")
+ local = os.path.join(local, "chrome")
+ self.device.push(local, remote)
+ self.chromePushed = True
+ return remote
+
+ def getLogFilePath(self, logFile):
+ return logFile
+
+ def getGMPPluginPath(self, options):
+ # TODO: bug 1149374
+ return None
+
+ def environment(self, env=None, crashreporter=True, **kwargs):
+ # Since running remote, do not mimic the local env: do not copy os.environ
+ if env is None:
+ env = {}
+
+ if crashreporter:
+ env["MOZ_CRASHREPORTER_NO_REPORT"] = "1"
+ env["MOZ_CRASHREPORTER"] = "1"
+ env["MOZ_CRASHREPORTER_SHUTDOWN"] = "1"
+ else:
+ env["MOZ_CRASHREPORTER_DISABLE"] = "1"
+
+ # Crash on non-local network connections by default.
+ # MOZ_DISABLE_NONLOCAL_CONNECTIONS can be set to "0" to temporarily
+ # enable non-local connections for the purposes of local testing.
+ # Don't override the user's choice here. See bug 1049688.
+ env.setdefault("MOZ_DISABLE_NONLOCAL_CONNECTIONS", "1")
+
+ # Send an env var noting that we are in automation. Passing any
+ # value except the empty string will declare the value to exist.
+ #
+ # This may be used to disabled network connections during testing, e.g.
+ # Switchboard & telemetry uploads.
+ env.setdefault("MOZ_IN_AUTOMATION", "1")
+
+ # Set WebRTC logging in case it is not set yet.
+ env.setdefault("R_LOG_LEVEL", "6")
+ env.setdefault("R_LOG_DESTINATION", "stderr")
+ env.setdefault("R_LOG_VERBOSE", "1")
+
+ return env
+
+ def buildBrowserEnv(self, options, debugger=False):
+ browserEnv = MochitestDesktop.buildBrowserEnv(self, options, debugger=debugger)
+ # remove desktop environment not used on device
+ if "XPCOM_MEM_BLOAT_LOG" in browserEnv:
+ del browserEnv["XPCOM_MEM_BLOAT_LOG"]
+ if self.mozLogs:
+ browserEnv["MOZ_LOG_FILE"] = os.path.join(
+ self.remoteMozLog, f"moz-pid=%PID-uid={str(uuid.uuid4())}.log"
+ )
+ if options.dmd:
+ browserEnv["DMD"] = "1"
+ # Contents of remoteMozLog will be pulled from device and copied to the
+ # host MOZ_UPLOAD_DIR, to be made available as test artifacts. Make
+ # MOZ_UPLOAD_DIR available to the browser environment so that tests
+ # can use it as though they were running on the host.
+ browserEnv["MOZ_UPLOAD_DIR"] = self.remoteMozLog
+ return browserEnv
+
+ def runApp(
+ self,
+ testUrl,
+ env,
+ app,
+ profile,
+ extraArgs,
+ utilityPath,
+ debuggerInfo=None,
+ valgrindPath=None,
+ valgrindArgs=None,
+ valgrindSuppFiles=None,
+ symbolsPath=None,
+ timeout=-1,
+ detectShutdownLeaks=False,
+ screenshotOnFail=False,
+ bisectChunk=None,
+ restartAfterFailure=False,
+ marionette_args=None,
+ e10s=True,
+ runFailures=False,
+ crashAsPass=False,
+ currentManifest=None,
+ ):
+ """
+ Run the app, log the duration it took to execute, return the status code.
+ Kill the app if it outputs nothing for |timeout| seconds.
+ """
+
+ if timeout == -1:
+ timeout = self.DEFAULT_TIMEOUT
+
+ status = 0
+ profileDirectory = self.remoteProfile + "/"
+ args = []
+ args.extend(extraArgs)
+ args.extend(("-no-remote", "-profile", profileDirectory))
+
+ environ = self.environment(env=env, crashreporter=not debuggerInfo)
+ environ["MOZ_TEST_URL"] = testUrl
+
+ # create an instance to process the output
+ outputHandler = self.OutputHandler(
+ harness=self,
+ utilityPath=utilityPath,
+ symbolsPath=symbolsPath,
+ dump_screen_on_timeout=not debuggerInfo,
+ dump_screen_on_fail=screenshotOnFail,
+ shutdownLeaks=None,
+ lsanLeaks=None,
+ bisectChunk=bisectChunk,
+ restartAfterFailure=restartAfterFailure,
+ )
+
+ proc = self.device.launch_process(
+ self.appName, args, env=environ, processOutputLine=[outputHandler]
+ )
+ proc.run(None, timeout)
+
+ status = proc.wait()
+ if status is None:
+ self.log.warning(
+ "runtestsremoteios.py | Failed to get app exit code - running/crashed?"
+ )
+ # must report an integer to process_exit()
+ status = 0
+ self.log.process_exit("Main app process", status)
+
+ # finalize output handler
+ outputHandler.finish()
+
+ lastTestSeen = currentManifest or "Main app process exited normally"
+
+ crashed = self.check_for_crashes(symbolsPath, lastTestSeen)
+ if crashed:
+ status = 1
+
+ return status, lastTestSeen
+
+ def check_for_crashes(self, symbols_path, last_test_seen):
+ """
+ Pull any minidumps from remote profile and log any associated crashes.
+ """
+ try:
+ dump_dir = tempfile.mkdtemp()
+ remote_crash_dir = posixpath.join(self.remoteProfile, "minidumps")
+ if not self.device.is_dir(remote_crash_dir):
+ return False
+ self.device.pull(remote_crash_dir, dump_dir)
+ crashed = mozcrash.log_crashes(
+ self.log, dump_dir, symbols_path, test=last_test_seen
+ )
+ finally:
+ try:
+ shutil.rmtree(dump_dir)
+ except Exception as e:
+ self.log.warning(f"unable to remove directory {dump_dir}: {str(e)}")
+ return crashed
+
+ # Override for the desktop output handler - used for simctl tests.
+ class OutputHandler(MochitestDesktop.OutputHandler):
+ # Disable the stack fixer, as there currently isn't one for iOS.
+ def stackFixer(self):
+ return None
+
+
+def run_test_harness(parser, options):
+ parser.validate(options)
+
+ if options is None:
+ raise ValueError(
+ "Invalid options specified, use --help for a list of valid options"
+ )
+
+ options.runByManifest = True
+
+ mochitest = MochiRemoteIos(options)
+
+ try:
+ if options.verify:
+ retVal = mochitest.verifyTests(options)
+ else:
+ retVal = mochitest.runTests(options)
+ except Exception:
+ mochitest.log.error("Automation Error: Exception caught while running tests")
+ traceback.print_exc()
+ try:
+ mochitest.cleanup(options)
+ except Exception:
+ # device error cleaning up... oh well!
+ traceback.print_exc()
+ retVal = 1
+
+ mochitest.archiveMozLogs()
+ mochitest.message_logger.finish()
+
+ return retVal
+
+
+def main(args=sys.argv[1:]):
+ parser = MochitestArgumentParser(app="ios")
+ options = parser.parse_args(args)
+
+ return run_test_harness(parser, options)
+
+
+if __name__ == "__main__":
+ sys.exit(main())