commit 3884dcda47f1a71125ee101416a5901f8dfd8740
parent 34b2f98faa68a2049a8f6d6cc57f2d0f94670805
Author: Nika Layzell <nika@thelayzells.com>
Date: Tue, 16 Dec 2025 04:53:45 +0000
Bug 1908693 - Part 6: Add some framework pieces for interacting with iOS devices, r=mach-reviewers,mozbase-reviewers,ahal,jmaher,firefox-build-system-reviewers,ahochheiden
This is roughly modeled on the framework used by Android devices, but is
slightly different. This definitely requires a lot of cleanup before it can be
more generally used.
The code is split between mozdevice and mozrunner. While there is some outline
code for running on a real iOS device, most of the core logic is missing, and
would need to be added.
Differential Revision: https://phabricator.services.mozilla.com/D217137
Diffstat:
4 files changed, 362 insertions(+), 0 deletions(-)
diff --git a/python/mozbuild/mozbuild/base.py b/python/mozbuild/mozbuild/base.py
@@ -998,6 +998,20 @@ class MachCommandConditions:
return False
@staticmethod
+ def is_ios(build_obj):
+ """Must have an iOS build."""
+ if hasattr(build_obj, "substs"):
+ return build_obj.substs.get("TARGET_OS") == "iOS"
+ return False
+
+ @staticmethod
+ def is_ios_simulator(build_obj):
+ """Must have an iOS simulator build."""
+ if hasattr(build_obj, "substs"):
+ return build_obj.substs.get("IPHONEOS_IS_SIMULATOR", False)
+ return False
+
+ @staticmethod
def is_android_cpu(build_obj):
"""Targeting Android CPU."""
if hasattr(build_obj, "substs"):
diff --git a/python/mozbuild/mozbuild/mach_commands.py b/python/mozbuild/mozbuild/mach_commands.py
@@ -1514,6 +1514,10 @@ def install(command_context, **kwargs):
return 0
+ elif conditions.is_ios(command_context):
+ from mozrunner.devices.ios_device import verify_ios_device
+
+ ret = verify_ios_device(command_context, install=True, **kwargs) == 0
else:
ret = command_context._run_make(
directory=".", target="install", ensure_exit_code=False
@@ -2183,6 +2187,38 @@ process attach {continue_flag}-p {pid!s}
device.shell("am clear-debug-app")
+def _run_ios(command_context, no_install=None, debug=False):
+ from mozdevice.ios import IosDevice
+ from mozrunner.devices.ios_device import (
+ verify_ios_device,
+ )
+
+ app = "org.mozilla.ios.GeckoTestBrowser"
+
+ # `verify_ios_device` respects sets `DEVICE_UUID`
+ verify_ios_device(
+ command_context,
+ app=app,
+ install=not no_install,
+ )
+ device_serial = os.environ.get("DEVICE_UUID")
+ if not device_serial:
+ print("No iOS devices connected.")
+ return 1
+
+ device = IosDevice.select_device(conditions.is_ios_simulator(command_context))
+ if debug:
+ print("Application will pause after starting until a debugger is connected...")
+ proc = device.launch_process(
+ app,
+ wait_for_debugger=debug,
+ stdout=None,
+ stderr=None,
+ )
+ proc.run()
+ proc.wait()
+
+
def _run_jsshell(command_context, params, debug, debugger, debugger_args):
try:
binpath = command_context.get_binary_path("app")
diff --git a/testing/mozbase/mozdevice/mozdevice/ios.py b/testing/mozbase/mozdevice/mozdevice/ios.py
@@ -0,0 +1,235 @@
+# 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/.
+
+"""mozdevice.ios is an experimental, early stages abstraction layer for running
+tests with Gecko on iOS devices. Currently this package only provides support
+for running tests on simulator, and is not ready for general-purpose use."""
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+
+from mozprocess import processhandler
+
+
+class IosDevice:
+ def __init__(self, uuid, name, is_simulator):
+ self.uuid = uuid
+ self.name = name
+ self.is_simulator = is_simulator
+
+ @staticmethod
+ def all_devices(is_simulator):
+ if is_simulator:
+ return IosDeviceSimulator.all_devices()
+ else:
+ return IosDeviceReal.all_devices()
+
+ @staticmethod
+ def select_device(is_simulator, device_uuid=None):
+ if device_uuid is None:
+ device_uuid = os.environ.get("DEVICE_UUID", None)
+
+ all_devices = IosDevice.all_devices(is_simulator)
+ for device in all_devices:
+ if device_uuid is not None:
+ if device.uuid == device_uuid:
+ return device
+ elif not device.is_simulator or device.state == "Booted":
+ return device
+ raise Exception(
+ "Couldn't find a booted and connected iOS device with the correct platform"
+ )
+
+
+# FIXME: Does not have support for the features of IosDeviceSimulator, nor does it
+# have the same API yet...
+class IosDeviceReal(IosDevice):
+ def __init__(self, uuid, name):
+ super().__init__(uuid, name, False)
+
+ def install(self, app_bundle):
+ subprocess.check_call(
+ [
+ "xcrun",
+ "devicectl",
+ "device",
+ "install",
+ "app",
+ "--device",
+ self.uuid,
+ app_bundle,
+ ]
+ )
+
+ @staticmethod
+ def all_devices():
+ with tempfile.NamedTemporaryFile() as tmpfile:
+ subprocess.check_call(
+ ["xcrun", "devicectl", "list", "devices", "-j", tmpfile.name],
+ stdout=subprocess.DEVNULL,
+ )
+ output = json.load(tmpfile)
+
+ if output["info"]["outcome"] != "success":
+ sys.stderr.write("Failed to read device list")
+ return []
+
+ return [
+ IosDeviceReal(device["identifier"], device["deviceProperties"]["name"])
+ for device in output["result"]["devices"]
+ ]
+
+
+class IosDeviceSimulator(IosDevice):
+ def __init__(self, uuid, name, runtime, datapath, logpath, state):
+ super().__init__(uuid, name, True)
+ self.runtime = runtime
+ self.datapath = datapath
+ self.logpath = logpath
+ self.state = state
+
+ def install(self, app_bundle):
+ subprocess.check_call(["xcrun", "simctl", "install", self.uuid, app_bundle])
+
+ def xcode_destination_specifier(self):
+ return "platform=iOS simulator,id=" + self.uuid
+
+ def launch_process(
+ self,
+ bundle_id,
+ args=[],
+ env=None,
+ wait_for_debugger=False,
+ terminate_running_process=True,
+ **kwargs
+ ):
+ # Put provided environment variables in `SIMCTL_CHILD_` so they
+ # propagate into the simulator.
+ kwargs["env"] = os.environ.copy()
+ if env:
+ for name, value in env.items():
+ kwargs["env"]["SIMCTL_CHILD_" + name] = value
+
+ # Specify provided flags
+ extra_args = []
+ if wait_for_debugger:
+ extra_args += ["--wait-for-debugger"]
+ if terminate_running_process:
+ extra_args += ["--terminate-running-process"]
+
+ # XXX: this should perhaps capture stdout/stderr with
+ # `--stdout/--stderr` rather than `--console`?
+
+ # FIXME: the ProcessHandlerMixin will have the pid for the xcrun
+ # command, not the actual app, which isn't great for debugging.
+
+ return processhandler.ProcessHandlerMixin(
+ [
+ "xcrun",
+ "simctl",
+ "launch",
+ *extra_args,
+ "--console",
+ self.uuid,
+ bundle_id,
+ *args,
+ ],
+ **kwargs
+ )
+
+ def test_root(self, bundle_id):
+ container_path = subprocess.check_output(
+ [
+ "xcrun",
+ "simctl",
+ "get_app_container",
+ self.uuid,
+ bundle_id,
+ "data",
+ ],
+ text=True,
+ )
+ return os.path.join(container_path.strip(), "test_root")
+
+ def rm(self, path, force=False, recursive=False):
+ try:
+ if recursive:
+ shutil.rmtree(path, ignore_errors=True)
+ else:
+ os.remove(path)
+ except Exception:
+ pass
+
+ def mkdir(self, path, parents=False):
+ if parents:
+ os.makedirs(path, exist_ok=True)
+ else:
+ os.mkdir(path)
+
+ def push(self, local, remote):
+ shutil.copytree(local, remote, dirs_exist_ok=True)
+
+ def pull(self, remote, local):
+ shutil.copytree(remote, local, dirs_exist_ok=True)
+
+ def chmod(self, path, recursive=False, mask="777"):
+ if recursive:
+ for root, dirs, files in os.walk(path):
+ for d in dirs:
+ os.chmod(os.path.join(root, d), int(mask, 8))
+ for f in files:
+ os.chmod(os.path.join(root, f), int(mask, 8))
+ else:
+ os.chmod(path, int(mask, 8))
+
+ def is_file(self, path):
+ return os.path.isfile(path)
+
+ def is_dir(self, path):
+ return os.path.isdir(path)
+
+ def get_file(self, path, offset=None, length=None):
+ with open(path, mode="rb") as f:
+ if offset is not None and length is not None:
+ f.seek(offset)
+ return f.read(length)
+ if offset is not None:
+ f.seek(offset)
+ return f.read()
+ return f.read()
+
+ def stop_application(self, bundle_id):
+ try:
+ subprocess.check_call(
+ ["xcrun", "simctl", "terminate", self.uuid, bundle_id]
+ )
+ except subprocess.CalledProcessError:
+ pass
+
+ @staticmethod
+ def all_devices():
+ output = json.loads(
+ subprocess.check_output(["xcrun", "simctl", "list", "devices", "-j"])
+ )
+
+ result = []
+ for runtime, devices in output["devices"].items():
+ for device in devices:
+ if not device["isAvailable"]:
+ continue
+ result.append(
+ IosDeviceSimulator(
+ device["udid"],
+ device["name"],
+ runtime,
+ device["dataPath"],
+ device["logPath"],
+ device["state"],
+ )
+ )
+ return result
diff --git a/testing/mozbase/mozrunner/mozrunner/devices/ios_device.py b/testing/mozbase/mozrunner/mozrunner/devices/ios_device.py
@@ -0,0 +1,77 @@
+import os
+import subprocess
+
+from mozbuild.base import MachCommandConditions as conditions
+from mozdevice.ios import IosDevice
+
+from .host_utils import ensure_host_utils
+
+
+def verify_ios_device(
+ build_obj,
+ install=False,
+ xre=False,
+ verbose=False,
+ app=None,
+):
+ is_simulator = conditions.is_ios_simulator(build_obj)
+
+ device_verified = False
+
+ devices = IosDevice.all_devices(is_simulator)
+ for device in devices:
+ if not device.is_simulator or device.state == "Booted":
+ device_verified = True
+ # FIXME: This roughly mimics how `verify_android_device` works but
+ # seems kinda jank - should we be copying this?
+ os.environ["DEVICE_UUID"] = device.uuid
+ break
+
+ if is_simulator and not device_verified:
+ # FIXME: Offer to launch a simulator here.
+ print("No iOS simulator started.")
+ return
+
+ if device_verified and install:
+ if not app:
+ app = "org.mozilla.ios.GeckoTestBrowser"
+
+ device = IosDevice.select_device(is_simulator)
+ if app == "org.mozilla.ios.GeckoTestBrowser":
+ # FIXME: This should probably be happening as a build step, rather
+ # than happening during verify_ios_device!
+ print("Packaging GeckoTestBrowser...")
+ subprocess.check_call(
+ [
+ "xcodebuild",
+ "-project",
+ os.path.join(
+ build_obj.topsrcdir,
+ "mobile/ios/GeckoTestBrowser/GeckoTestBrowser.xcodeproj",
+ ),
+ "-scheme",
+ "GeckoTestBrowser",
+ "-destination",
+ device.xcode_destination_specifier(),
+ "install",
+ "DSTROOT=" + build_obj.distdir,
+ "TOPOBJDIR=" + build_obj.topobjdir,
+ ]
+ )
+
+ print("Installing GeckoTestBrowser...")
+ device.install(
+ os.path.join(build_obj.distdir, "Applications/GeckoTestBrowser.app")
+ )
+ else:
+ # FIXME: If the app is already installed, don't prompt the user here
+ # to align with verify_android_device.
+ input(
+ "Application %s cannot be automatically installed\n"
+ "Install it now, then hit Enter " % app
+ )
+
+ if device_verified and xre:
+ ensure_host_utils(build_obj, verbose)
+
+ return device_verified