diff options
Diffstat (limited to 'python/mozperftest/mozperftest/system')
-rw-r--r-- | python/mozperftest/mozperftest/system/__init__.py | 35 | ||||
-rw-r--r-- | python/mozperftest/mozperftest/system/android.py | 238 | ||||
-rw-r--r-- | python/mozperftest/mozperftest/system/android_perf_tuner.py | 193 | ||||
-rw-r--r-- | python/mozperftest/mozperftest/system/android_startup.py | 414 | ||||
-rw-r--r-- | python/mozperftest/mozperftest/system/example.zip | bin | 0 -> 6588776 bytes | |||
-rw-r--r-- | python/mozperftest/mozperftest/system/macos.py | 120 | ||||
-rw-r--r-- | python/mozperftest/mozperftest/system/pingserver.py | 94 | ||||
-rw-r--r-- | python/mozperftest/mozperftest/system/profile.py | 122 | ||||
-rw-r--r-- | python/mozperftest/mozperftest/system/proxy.py | 232 |
9 files changed, 1448 insertions, 0 deletions
diff --git a/python/mozperftest/mozperftest/system/__init__.py b/python/mozperftest/mozperftest/system/__init__.py new file mode 100644 index 0000000000..55deb9094d --- /dev/null +++ b/python/mozperftest/mozperftest/system/__init__.py @@ -0,0 +1,35 @@ +# 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 mozperftest.layers import Layers +from mozperftest.system.android import AndroidDevice +from mozperftest.system.android_startup import AndroidStartUp +from mozperftest.system.macos import MacosDevice +from mozperftest.system.pingserver import PingServer +from mozperftest.system.profile import Profile +from mozperftest.system.proxy import ProxyRunner + + +def get_layers(): + return PingServer, Profile, ProxyRunner, AndroidDevice, MacosDevice, AndroidStartUp + + +def pick_system(env, flavor, mach_cmd): + if flavor in ("desktop-browser", "xpcshell"): + return Layers( + env, + mach_cmd, + ( + PingServer, # needs to come before Profile + MacosDevice, + Profile, + ProxyRunner, + ), + ) + if flavor == "mobile-browser": + return Layers( + env, mach_cmd, (Profile, ProxyRunner, AndroidDevice, AndroidStartUp) + ) + if flavor == "webpagetest": + return Layers(env, mach_cmd, (Profile,)) + raise NotImplementedError(flavor) diff --git a/python/mozperftest/mozperftest/system/android.py b/python/mozperftest/mozperftest/system/android.py new file mode 100644 index 0000000000..650b0fb29d --- /dev/null +++ b/python/mozperftest/mozperftest/system/android.py @@ -0,0 +1,238 @@ +# 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 sys +import tempfile +from pathlib import Path + +import mozlog +from mozdevice import ADBDevice, ADBError + +from mozperftest.layers import Layer +from mozperftest.system.android_perf_tuner import tune_performance +from mozperftest.utils import download_file + +HERE = Path(__file__).parent + +_ROOT_URL = "https://firefox-ci-tc.services.mozilla.com/api/index/v1/task/" +_FENIX_NIGHTLY_BUILDS = ( + "mobile.v3.firefox-android.apks.fenix-nightly.latest.{architecture}" + "/artifacts/public/build/fenix/{architecture}/target.apk" +) +_GV_BUILDS = "gecko.v2.mozilla-central.shippable.latest.mobile.android-" +_REFBROW_BUILDS = ( + "mobile.v2.reference-browser.nightly.latest.{architecture}" + "/artifacts/public/target.{architecture}.apk" +) + +_PERMALINKS = { + "fenix_nightly_armeabi_v7a": _ROOT_URL + + _FENIX_NIGHTLY_BUILDS.format(architecture="armeabi-v7a"), + "fenix_nightly_arm64_v8a": _ROOT_URL + + _FENIX_NIGHTLY_BUILDS.format(architecture="arm64-v8a"), + # The two following aliases are used for Fenix multi-commit testing in CI + "fenix_nightlysim_multicommit_arm64_v8a": None, + "fenix_nightlysim_multicommit_armeabi_v7a": None, + "gve_nightly_aarch64": _ROOT_URL + + _GV_BUILDS + + "aarch64-opt/artifacts/public/build/geckoview_example.apk", + "gve_nightly_api16": _ROOT_URL + + _GV_BUILDS + + "arm-opt/artifacts/public/build/geckoview_example.apk", + "refbrow_nightly_aarch64": _ROOT_URL + + _REFBROW_BUILDS.format(architecture="arm64-v8a"), + "refbrow_nightly_api16": _ROOT_URL + + _REFBROW_BUILDS.format(architecture="armeabi-v7a"), +} + + +class DeviceError(Exception): + pass + + +class ADBLoggedDevice(ADBDevice): + def __init__(self, *args, **kw): + self._provided_logger = kw.pop("logger") + super(ADBLoggedDevice, self).__init__(*args, **kw) + + def _get_logger(self, logger_name, verbose): + return self._provided_logger + + +class AndroidDevice(Layer): + """Use an android device via ADB""" + + name = "android" + activated = False + + arguments = { + "app-name": { + "type": str, + "default": "org.mozilla.firefox", + "help": "Android app name", + }, + "timeout": { + "type": int, + "default": 60, + "help": "Timeout in seconds for adb operations", + }, + "clear-logcat": { + "action": "store_true", + "default": False, + "help": "Clear the logcat when starting", + }, + "capture-adb": { + "type": str, + "default": "stdout", + "help": ( + "Captures adb calls to the provided path. " + "To capture to stdout, use 'stdout'." + ), + }, + "capture-logcat": { + "type": str, + "default": None, + "help": "Captures the logcat to the provided path.", + }, + "perf-tuning": { + "action": "store_true", + "default": False, + "help": ( + "If set, device will be tuned for performance. " + "This helps with decreasing the noise." + ), + }, + "intent": {"type": str, "default": None, "help": "Intent to use"}, + "activity": {"type": str, "default": None, "help": "Activity to use"}, + "install-apk": { + "nargs": "*", + "default": [], + "help": ( + "APK to install to the device " + "Can be a file, an url or an alias url from " + " %s" % ", ".join(_PERMALINKS.keys()) + ), + }, + } + + def __init__(self, env, mach_cmd): + super(AndroidDevice, self).__init__(env, mach_cmd) + self.android_activity = self.app_name = self.device = None + self.capture_logcat = self.capture_file = None + self._custom_apk_path = None + + @property + def custom_apk_path(self): + if self._custom_apk_path is None: + custom_apk_path = Path(HERE, "..", "user_upload.apk") + if custom_apk_path.exists(): + self._custom_apk_path = custom_apk_path + return self._custom_apk_path + + def custom_apk_exists(self): + return self.custom_apk_path is not None + + def setup(self): + if self.custom_apk_exists(): + self.info( + f"Replacing --android-install-apk with custom APK found at " + f"{self.custom_apk_path}" + ) + self.set_arg("android-install-apk", [self.custom_apk_path]) + + def teardown(self): + if self.capture_file is not None: + self.capture_file.close() + if self.capture_logcat is not None and self.device is not None: + self.info("Dumping logcat into %r" % str(self.capture_logcat)) + with self.capture_logcat.open("wb") as f: + for line in self.device.get_logcat(): + f.write(line.encode("utf8", errors="replace") + b"\n") + + def _set_output_path(self, path): + if path in (None, "stdout"): + return path + # check if the path is absolute or relative to output + path = Path(path) + if not path.is_absolute(): + return Path(self.get_arg("output"), path) + return path + + def run(self, metadata): + self.app_name = self.get_arg("android-app-name") + self.android_activity = self.get_arg("android-activity") + self.clear_logcat = self.get_arg("clear-logcat") + self.metadata = metadata + self.verbose = self.get_arg("verbose") + self.capture_adb = self._set_output_path(self.get_arg("capture-adb")) + self.capture_logcat = self._set_output_path(self.get_arg("capture-logcat")) + + # capture the logs produced by ADBDevice + logger_name = "mozperftest-adb" + logger = mozlog.structuredlog.StructuredLogger(logger_name) + if self.capture_adb == "stdout": + stream = sys.stdout + disable_colors = False + else: + stream = self.capture_file = self.capture_adb.open("w") + disable_colors = True + + handler = mozlog.handlers.StreamHandler( + stream=stream, + formatter=mozlog.formatters.MachFormatter( + verbose=self.verbose, disable_colors=disable_colors + ), + ) + logger.add_handler(handler) + try: + self.device = ADBLoggedDevice( + verbose=self.verbose, timeout=self.get_arg("timeout"), logger=logger + ) + except (ADBError, AttributeError) as e: + self.error("Could not connect to the phone. Is it connected?") + raise DeviceError(str(e)) + + if self.clear_logcat: + self.device.clear_logcat() + + # Install APKs + for apk in self.get_arg("android-install-apk"): + self.info("Uninstalling old version") + self.device.uninstall_app(self.get_arg("android-app-name")) + self.info("Installing %s" % apk) + if str(apk) in _PERMALINKS: + apk = _PERMALINKS[apk] + if str(apk).startswith("http"): + with tempfile.TemporaryDirectory() as tmpdirname: + target = Path(tmpdirname, "target.apk") + self.info("Downloading %s" % apk) + download_file(apk, target) + self.info("Installing downloaded APK") + self.device.install_app(str(target)) + else: + self.device.install_app(apk, replace=True) + self.info("Done.") + + # checking that the app is installed + if not self.device.is_app_installed(self.app_name): + raise Exception("%s is not installed" % self.app_name) + + if self.get_arg("android-perf-tuning", False): + tune_performance(self.device) + + # set up default activity with the app name if none given + if self.android_activity is None: + # guess the activity, given the app + if "fenix" in self.app_name: + self.android_activity = "org.mozilla.fenix.IntentReceiverActivity" + elif "geckoview_example" in self.app_name: + self.android_activity = ( + "org.mozilla.geckoview_example.GeckoViewActivity" + ) + self.set_arg("android_activity", self.android_activity) + + self.info("Android environment:") + self.info("- Application name: %s" % self.app_name) + self.info("- Activity: %s" % self.android_activity) + self.info("- Intent: %s" % self.get_arg("android_intent")) + return metadata diff --git a/python/mozperftest/mozperftest/system/android_perf_tuner.py b/python/mozperftest/mozperftest/system/android_perf_tuner.py new file mode 100644 index 0000000000..924ddd0c9e --- /dev/null +++ b/python/mozperftest/mozperftest/system/android_perf_tuner.py @@ -0,0 +1,193 @@ +# 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/. + + +def tune_performance(device, log=None, timeout=None): + """Set various performance-oriented parameters, to reduce jitter. + + This includes some device-specific kernel tweaks. + + For more information, see https://bugzilla.mozilla.org/show_bug.cgi?id=1547135. + """ + PerformanceTuner(device, log=log, timeout=timeout).tune_performance() + + +class PerformanceTuner: + def __init__(self, device, log=None, timeout=None): + self.device = device + self.log = log is not None and log or self.device._logger + self.timeout = timeout + + def tune_performance(self): + self.log.info("tuning android device performance") + self.set_svc_power_stayon() + if self.device.is_rooted: + device_name = self.device.shell_output( + "getprop ro.product.model", timeout=self.timeout + ) + # all commands require root shell from here on + self.set_scheduler() + self.set_virtual_memory_parameters() + self.turn_off_services() + self.set_cpu_performance_parameters(device_name) + self.set_gpu_performance_parameters(device_name) + self.set_kernel_performance_parameters() + self.device.clear_logcat(timeout=self.timeout) + self.log.info("android device performance tuning complete") + + def _set_value_and_check_exitcode(self, file_name, value): + self.log.info("setting {} to {}".format(file_name, value)) + if self.device.shell_bool( + " ".join(["echo", str(value), ">", str(file_name)]), + timeout=self.timeout, + ): + self.log.info("successfully set {} to {}".format(file_name, value)) + else: + self.log.warning("command failed") + + def set_svc_power_stayon(self): + self.log.info("set device to stay awake on usb") + self.device.shell_bool("svc power stayon usb", timeout=self.timeout) + + def set_scheduler(self): + self.log.info("setting scheduler to noop") + scheduler_location = "/sys/block/sda/queue/scheduler" + + self._set_value_and_check_exitcode(scheduler_location, "noop") + + def turn_off_services(self): + services = [ + "mpdecision", + "thermal-engine", + "thermald", + ] + for service in services: + self.log.info(" ".join(["turning off service:", service])) + self.device.shell_bool(" ".join(["stop", service]), timeout=self.timeout) + + services_list_output = self.device.shell_output( + "service list", timeout=self.timeout + ) + for service in services: + if service not in services_list_output: + self.log.info(" ".join(["successfully terminated:", service])) + else: + self.log.warning(" ".join(["failed to terminate:", service])) + + def set_virtual_memory_parameters(self): + self.log.info("setting virtual memory parameters") + commands = { + "/proc/sys/vm/swappiness": 0, + "/proc/sys/vm/dirty_ratio": 85, + "/proc/sys/vm/dirty_background_ratio": 70, + } + + for key, value in commands.items(): + self._set_value_and_check_exitcode(key, value) + + def set_cpu_performance_parameters(self, device_name=None): + self.log.info("setting cpu performance parameters") + commands = {} + + if device_name is not None: + device_name = self.device.shell_output( + "getprop ro.product.model", timeout=self.timeout + ) + + if device_name == "Pixel 2": + # MSM8998 (4x 2.35GHz, 4x 1.9GHz) + # values obtained from: + # /sys/devices/system/cpu/cpufreq/policy0/scaling_available_frequencies + # /sys/devices/system/cpu/cpufreq/policy4/scaling_available_frequencies + commands.update( + { + "/sys/devices/system/cpu/cpufreq/policy0/scaling_governor": "performance", + "/sys/devices/system/cpu/cpufreq/policy4/scaling_governor": "performance", + "/sys/devices/system/cpu/cpufreq/policy0/scaling_min_freq": "1900800", + "/sys/devices/system/cpu/cpufreq/policy4/scaling_min_freq": "2457600", + } + ) + elif device_name == "Moto G (5)": + # MSM8937(8x 1.4GHz) + # values obtained from: + # /sys/devices/system/cpu/cpufreq/policy0/scaling_available_frequencies + for x in range(0, 8): + commands.update( + { + "/sys/devices/system/cpu/cpu{}/" + "cpufreq/scaling_governor".format(x): "performance", + "/sys/devices/system/cpu/cpu{}/" + "cpufreq/scaling_min_freq".format(x): "1401000", + } + ) + else: + self.log.info( + "CPU for device with ro.product.model '{}' unknown, not scaling_governor".format( + device_name + ) + ) + + for key, value in commands.items(): + self._set_value_and_check_exitcode(key, value) + + def set_gpu_performance_parameters(self, device_name=None): + self.log.info("setting gpu performance parameters") + commands = { + "/sys/class/kgsl/kgsl-3d0/bus_split": "0", + "/sys/class/kgsl/kgsl-3d0/force_bus_on": "1", + "/sys/class/kgsl/kgsl-3d0/force_rail_on": "1", + "/sys/class/kgsl/kgsl-3d0/force_clk_on": "1", + "/sys/class/kgsl/kgsl-3d0/force_no_nap": "1", + "/sys/class/kgsl/kgsl-3d0/idle_timer": "1000000", + } + + if not device_name: + device_name = self.device.shell_output( + "getprop ro.product.model", timeout=self.timeout + ) + + if device_name == "Pixel 2": + # Adreno 540 (710MHz) + # values obtained from: + # /sys/devices/soc/5000000.qcom,kgsl-3d0/kgsl/kgsl-3d0/max_clk_mhz + commands.update( + { + "/sys/devices/soc/5000000.qcom,kgsl-3d0/devfreq/" + "5000000.qcom,kgsl-3d0/governor": "performance", + "/sys/devices/soc/soc:qcom,kgsl-busmon/devfreq/" + "soc:qcom,kgsl-busmon/governor": "performance", + "/sys/devices/soc/5000000.qcom,kgsl-3d0/kgsl/kgsl-3d0/min_clock_mhz": "710", + } + ) + elif device_name == "Moto G (5)": + # Adreno 505 (450MHz) + # values obtained from: + # /sys/devices/soc/1c00000.qcom,kgsl-3d0/kgsl/kgsl-3d0/max_clock_mhz + commands.update( + { + "/sys/devices/soc/1c00000.qcom,kgsl-3d0/devfreq/" + "1c00000.qcom,kgsl-3d0/governor": "performance", + "/sys/devices/soc/1c00000.qcom,kgsl-3d0/kgsl/kgsl-3d0/min_clock_mhz": "450", + } + ) + else: + self.log.info( + "GPU for device with ro.product.model '{}' unknown, not setting devfreq".format( + device_name + ) + ) + + for key, value in commands.items(): + self._set_value_and_check_exitcode(key, value) + + def set_kernel_performance_parameters(self): + self.log.info("setting kernel performance parameters") + commands = { + "/sys/kernel/debug/msm-bus-dbg/shell-client/update_request": "1", + "/sys/kernel/debug/msm-bus-dbg/shell-client/mas": "1", + "/sys/kernel/debug/msm-bus-dbg/shell-client/ab": "0", + "/sys/kernel/debug/msm-bus-dbg/shell-client/slv": "512", + } + for key, value in commands.items(): + self._set_value_and_check_exitcode(key, value) diff --git a/python/mozperftest/mozperftest/system/android_startup.py b/python/mozperftest/mozperftest/system/android_startup.py new file mode 100644 index 0000000000..4717c638ae --- /dev/null +++ b/python/mozperftest/mozperftest/system/android_startup.py @@ -0,0 +1,414 @@ +# 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 re +import statistics +import time +from datetime import datetime, timedelta + +import mozdevice + +from .android import AndroidDevice + +DATETIME_FORMAT = "%Y.%m.%d" +PAGE_START = re.compile("GeckoSession: handleMessage GeckoView:PageStart uri=") + +PROD_FENIX = "fenix" +PROD_FOCUS = "focus" +PROC_GVEX = "geckoview_example" + +KEY_NAME = "name" +KEY_PRODUCT = "product" +KEY_DATETIME = "date" +KEY_COMMIT = "commit" +KEY_ARCHITECTURE = "architecture" +KEY_TEST_NAME = "test_name" + +MEASUREMENT_DATA = ["mean", "median", "standard_deviation"] +OLD_VERSION_FOCUS_PAGE_START_LINE_COUNT = 3 +NEW_VERSION_FOCUS_PAGE_START_LINE_COUNT = 2 +STDOUT_LINE_COUNT = 2 + +TEST_COLD_MAIN_FF = "cold_main_first_frame" +TEST_COLD_MAIN_RESTORE = "cold_main_session_restore" +TEST_COLD_VIEW_FF = "cold_view_first_frame" +TEST_COLD_VIEW_NAV_START = "cold_view_nav_start" +TEST_URI = "https://example.com" + +BASE_URL_DICT = { + PROD_FENIX: ( + "https://firefox-ci-tc.services.mozilla.com/api/index/v1/task/" + "mobile.v3.firefox-android.apks.fenix-nightly.{date}.latest.{architecture}/artifacts/" + "public%2Fbuild%2Ffenix%2F{architecture}%2Ftarget.apk" + ), + PROD_FENIX + + "-latest": ( + "https://firefox-ci-tc.services.mozilla.com/api/index/v1/task/" + "mobile.v3.firefox-android.apks.fenix-nightly.latest.{architecture}/artifacts/" + "public%2Fbuild%2Ffenix%2F{architecture}%2Ftarget.apk" + ), + PROD_FOCUS: ( + "https://firefox-ci-tc.services.mozilla.com/api/index/v1/task/" + "mobile.v3.firefox-android.apks.focus-nightly.{date}.latest.{architecture}" + "/artifacts/public%2Fbuild%2Ffocus%2F{architecture}%2Ftarget.apk" + ), + PROD_FOCUS + + "-latest": ( + "https://firefox-ci-tc.services.mozilla.com/api/index/v1/task/" + "mobile.v3.firefox-android.apks.focus-nightly.latest.{architecture}" + "/artifacts/public%2Fbuild%2Ffocus%2F{architecture}%2Ftarget.apk" + ), + PROC_GVEX: ( + "https://firefox-ci-tc.services.mozilla.com/api/index/v1/task/" + "gecko.v2.mozilla-central.pushdate.{date}.latest.mobile.android-" + "{architecture}-debug/artifacts/public%2Fbuild%2Fgeckoview_example.apk" + ), + PROC_GVEX + + "-latest": ( + "https://firefox-ci-tc.services.mozilla.com/api/index/v1/task/" + "gecko.v2.mozilla-central.shippable.latest.mobile.android-" + "{architecture}-opt/artifacts/public/build/geckoview_example.apk" + ), +} +PROD_TO_CHANNEL_TO_PKGID = { + PROD_FENIX: { + "nightly": "org.mozilla.fenix", + "beta": "org.mozilla.firefox.beta", + "release": "org.mozilla.firefox", + "debug": "org.mozilla.fenix.debug", + }, + PROD_FOCUS: { + "nightly": "org.mozilla.focus.nightly", + "beta": "org.mozilla.focus.beta", # only present since post-fenix update. + "release": "org.mozilla.focus", + "debug": "org.mozilla.focus.debug", + }, + PROC_GVEX: { + "nightly": "org.mozilla.geckoview_example", + }, +} +TEST_LIST = [ + "cold_main_first_frame", + "cold_view_nav_start", + "cold_view_first_frame", + "cold_main_session_restore", +] +# "cold_view_first_frame", "cold_main_session_restore" are 2 disabled tests(broken) + + +class AndroidStartUpDownloadError(Exception): + """Failure downloading Firefox Nightly APK""" + + pass + + +class AndroidStartUpInstallError(Exception): + """Failure installing Firefox on the android device""" + + pass + + +class AndroidStartUpUnknownTestError(Exception): + """ + Test name provided is not one avaiable to test, this is either because + the test is currently not being tested or a typo in the spelling + """ + + pass + + +class AndroidStartUpMatchingError(Exception): + """ + We expected a certain number of matches but did not get them + """ + + pass + + +class AndroidStartUpData: + def open_data(self, data): + return { + "name": "AndroidStartUp", + "subtest": data["name"], + "data": [ + {"file": "android_startup", "value": value, "xaxis": xaxis} + for xaxis, value in enumerate(data["values"]) + ], + "shouldAlert": True, + } + + def transform(self, data): + return data + + merge = transform + + +class AndroidStartUp(AndroidDevice): + name = "AndroidStartUp" + activated = False + arguments = { + "test-name": { + "type": str, + "default": "", + "help": "This is the startup android test that will be run on the a51", + }, + "apk_metadata": { + "type": str, + "default": "", + "help": "This is the startup android test that will be run on the a51", + }, + "product": { + "type": str, + "default": "", + "help": "This is the startup android test that will be run on the a51", + }, + "release-channel": { + "type": str, + "default": "", + "help": "This is the startup android test that will be run on the a51", + }, + } + + def __init__(self, env, mach_cmd): + super(AndroidStartUp, self).__init__(env, mach_cmd) + self.android_activity = None + self.capture_logcat = self.capture_file = self.app_name = None + self.device = mozdevice.ADBDevice(use_root=False) + + def run(self, metadata): + options = metadata.script["options"] + self.test_name = self.get_arg("test-name") + self.apk_metadata = self.get_arg("apk-metadata") + self.product = self.get_arg("product") + self.release_channel = self.get_arg("release_channel") + self.single_date = options["test_parameters"]["single_date"] + self.date_range = options["test_parameters"]["date_range"] + self.startup_cache = options["test_parameters"]["startup_cache"] + self.test_cycles = options["test_parameters"]["test_cycles"] + self.package_id = PROD_TO_CHANNEL_TO_PKGID[self.product][self.release_channel] + self.proc_start = re.compile( + rf"ActivityManager: Start proc \d+:{self.package_id}/" + ) + + apk_metadata = self.apk_metadata + self.get_measurements(apk_metadata, metadata) + + # Cleanup + self.device.shell(f"rm {apk_metadata[KEY_NAME]}") + + return metadata + + def get_measurements(self, apk_metadata, metadata): + measurements = self.run_performance_analysis(apk_metadata) + self.add_to_metadata(measurements, metadata) + + def get_date_array_for_range(self, start, end): + startdate = datetime.strptime(start, DATETIME_FORMAT) + enddate = datetime.strptime(end, DATETIME_FORMAT) + delta_dates = (enddate - startdate).days + 1 + return [ + (startdate + timedelta(days=i)).strftime("%Y.%m.%d") + for i in range(delta_dates) + ] + + def add_to_metadata(self, measurements, metadata): + if measurements is not None: + for key, value in measurements.items(): + metadata.add_result( + { + "name": f"AndroidStartup:{self.product}", + "framework": {"name": "mozperftest"}, + "transformer": "mozperftest.system.android_startup:AndroidStartUpData", + "shouldAlert": True, + "results": [ + { + "values": [value], + "name": key, + "shouldAlert": True, + } + ], + } + ) + + def run_performance_analysis(self, apk_metadata): + # Installing the application on the device and getting ready to run the tests + install_path = apk_metadata[KEY_NAME] + if self.custom_apk_exists(): + install_path = self.custom_apk_path + + self.device.uninstall_app(self.package_id) + self.info(f"Installing {install_path}...") + app_name = self.device.install_app(install_path) + if self.device.is_app_installed(app_name): + self.info(f"Successfully installed {app_name}") + else: + raise AndroidStartUpInstallError("The android app was not installed") + self.apk_name = apk_metadata[KEY_NAME].split(".")[0] + + return self.run_tests() + + def run_tests(self): + measurements = {} + # Iterate through the tests in the test list + self.info(f"Running {self.test_name} on {self.apk_name}...") + self.skip_onboarding(self.test_name) + time.sleep(self.get_warmup_delay_seconds()) + test_measurements = [] + + for i in range(self.test_cycles): + start_cmd_args = self.get_start_cmd(self.test_name) + self.info(start_cmd_args) + self.device.stop_application(self.package_id) + time.sleep(1) + self.info(f"iteration {i + 1}") + self.device.shell("logcat -c") + process = self.device.shell_output(start_cmd_args).splitlines() + test_measurements.append(self.get_measurement(self.test_name, process)) + + self.info(f"{self.test_name}: {str(test_measurements)}") + measurements[f"{self.test_name}.{MEASUREMENT_DATA[0]}"] = statistics.mean( + test_measurements + ) + self.info(f"Mean: {statistics.mean(test_measurements)}") + measurements[f"{self.test_name}.{MEASUREMENT_DATA[1]}"] = statistics.median( + test_measurements + ) + self.info(f"Median: {statistics.median(test_measurements)}") + if self.test_cycles > 1: + measurements[f"{self.test_name}.{MEASUREMENT_DATA[2]}"] = statistics.stdev( + test_measurements + ) + self.info(f"Standard Deviation: {statistics.stdev(test_measurements)}") + + return measurements + + def get_measurement(self, test_name, stdout): + if test_name in [TEST_COLD_MAIN_FF, TEST_COLD_VIEW_FF]: + return self.get_measurement_from_am_start_log(stdout) + elif test_name in [TEST_COLD_VIEW_NAV_START, TEST_COLD_MAIN_RESTORE]: + # We must sleep until the Navigation::Start event occurs. If we don't + # the script will fail. This can take up to 14s on the G5 + time.sleep(17) + proc = self.device.shell_output("logcat -d") + return self.get_measurement_from_nav_start_logcat(proc) + + def get_measurement_from_am_start_log(self, stdout): + total_time_prefix = "TotalTime: " + matching_lines = [line for line in stdout if line.startswith(total_time_prefix)] + if len(matching_lines) != 1: + raise AndroidStartUpMatchingError( + f"Each run should only have 1 {total_time_prefix}." + f"However, this run unexpectedly had {matching_lines} matching lines" + ) + duration = int(matching_lines[0][len(total_time_prefix) :]) + return duration + + def get_measurement_from_nav_start_logcat(self, process_output): + def __line_to_datetime(line): + date_str = " ".join(line.split(" ")[:2]) # e.g. "05-18 14:32:47.366" + # strptime needs microseconds. logcat outputs millis so we append zeroes + date_str_with_micros = date_str + "000" + return datetime.strptime(date_str_with_micros, "%m-%d %H:%M:%S.%f") + + def __get_proc_start_datetime(): + # This regex may not work on older versions of Android: we don't care + # yet because supporting older versions isn't in our requirements. + proc_start_lines = [line for line in lines if self.proc_start.search(line)] + if len(proc_start_lines) != 1: + raise AndroidStartUpMatchingError( + f"Expected to match 1 process start string but matched {len(proc_start_lines)}" + ) + return __line_to_datetime(proc_start_lines[0]) + + def __get_page_start_datetime(): + page_start_lines = [line for line in lines if PAGE_START.search(line)] + page_start_line_count = len(page_start_lines) + page_start_assert_msg = "found len=" + str(page_start_line_count) + + # In focus versions <= v8.8.2, it logs 3 PageStart lines and these include actual uris. + # We need to handle our assertion differently due to the different line count. In focus + # versions >= v8.8.3, this measurement is broken because the logcat were removed. + is_old_version_of_focus = ( + "about:blank" in page_start_lines[0] and self.product == PROD_FOCUS + ) + if is_old_version_of_focus: + assert ( + page_start_line_count + == OLD_VERSION_FOCUS_PAGE_START_LINE_COUNT # should be 3 + ), page_start_assert_msg # Lines: about:blank, target URL, target URL. + else: + assert ( + page_start_line_count + == NEW_VERSION_FOCUS_PAGE_START_LINE_COUNT # Should be 2 + ), page_start_assert_msg # Lines: about:blank, target URL. + return __line_to_datetime( + page_start_lines[1] + ) # 2nd PageStart is for target URL. + + lines = process_output.split("\n") + elapsed_seconds = ( + __get_page_start_datetime() - __get_proc_start_datetime() + ).total_seconds() + elapsed_millis = round(elapsed_seconds * 1000) + return elapsed_millis + + def get_warmup_delay_seconds(self): + """ + We've been told the start up cache is populated ~60s after first start up. As such, + we should measure start up with the start up cache populated. If the + args say we shouldn't wait, we only wait a short duration ~= visual completeness. + """ + return 60 if self.startup_cache else 5 + + def get_start_cmd(self, test_name): + intent_action_prefix = "android.intent.action.{}" + if test_name in [TEST_COLD_MAIN_FF, TEST_COLD_MAIN_RESTORE]: + intent = ( + f"-a {intent_action_prefix.format('MAIN')} " + f"-c android.intent.category.LAUNCHER" + ) + elif test_name in [TEST_COLD_VIEW_FF, TEST_COLD_VIEW_NAV_START]: + intent = f"-a {intent_action_prefix.format('VIEW')} -d {TEST_URI}" + else: + raise AndroidStartUpUnknownTestError( + "Unknown test provided please double check the test name and spelling" + ) + + # You can't launch an app without an pkg_id/activity pair + component_name = self.get_component_name_for_intent(intent) + cmd = f"am start-activity -W -n {component_name} {intent} " + + # If focus skip onboarding: it is not stateful so must be sent for every cold start intent + if self.product == PROD_FOCUS: + cmd += "--ez performancetest true" + + return cmd + + def get_component_name_for_intent(self, intent): + resolve_component_args = ( + f"cmd package resolve-activity --brief {intent} {self.package_id}" + ) + result_output = self.device.shell_output(resolve_component_args) + stdout = result_output.splitlines() + if len(stdout) != STDOUT_LINE_COUNT: # Should be 2 + raise AndroidStartUpMatchingError(f"expected 2 lines. Got: {stdout}") + return stdout[1] + + def skip_onboarding(self, test_name): + """ + We skip onboarding for focus in measure_start_up.py because it's stateful + and needs to be called for every cold start intent. + Onboarding only visibly gets in the way of our MAIN test results. + """ + if self.product == PROD_FOCUS or test_name not in { + TEST_COLD_MAIN_FF, + TEST_COLD_MAIN_RESTORE, + }: + return + + # This sets mutable state so we only need to pass this flag once, before we start the test + self.device.shell( + f"am start-activity -W -a android.intent.action.MAIN --ez " + f"performancetest true -n{self.package_id}/org.mozilla.fenix.App" + ) + time.sleep(4) # ensure skip onboarding call has time to propagate. diff --git a/python/mozperftest/mozperftest/system/example.zip b/python/mozperftest/mozperftest/system/example.zip Binary files differnew file mode 100644 index 0000000000..8c724762d3 --- /dev/null +++ b/python/mozperftest/mozperftest/system/example.zip diff --git a/python/mozperftest/mozperftest/system/macos.py b/python/mozperftest/mozperftest/system/macos.py new file mode 100644 index 0000000000..493fd4fc1d --- /dev/null +++ b/python/mozperftest/mozperftest/system/macos.py @@ -0,0 +1,120 @@ +# 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 platform +import shutil +import subprocess +import tempfile +from pathlib import Path + +from mozperftest.layers import Layer + +# Add here any option that might point to a DMG file we want to extract. The key +# is name of the option and the value, the file in the DMG we want to use for +# the option. +POTENTIAL_DMGS = { + "browsertime-binary": "Contents/MacOS/firefox", + "xpcshell-xre-path": "Contents/MacOS", +} + + +class MacosDevice(Layer): + """Runs on macOS to mount DMGs if we see one.""" + + name = "macos" + activated = platform.system() == "Darwin" + + def __init__(self, env, mach_cmd): + super(MacosDevice, self).__init__(env, mach_cmd) + self._tmp_dirs = [] + + def _run_process(self, args): + p = subprocess.Popen( + args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + ) + + stdout, stderr = p.communicate(timeout=45) + if p.returncode != 0: + raise subprocess.CalledProcessError( + stdout=stdout, stderr=stderr, returncode=p.returncode + ) + + return stdout + + def extract_app(self, dmg, target): + mount = Path(tempfile.mkdtemp()) + + if not Path(dmg).exists(): + raise FileNotFoundError(dmg) + + # mounting the DMG with hdiutil + cmd = f"hdiutil attach -nobrowse -mountpoint {str(mount)} {dmg}" + try: + self._run_process(cmd.split()) + except subprocess.CalledProcessError: + self.error(f"Can't mount {dmg}") + if mount.exists(): + shutil.rmtree(str(mount)) + raise + + # browse the mounted volume, to look for the app. + found = False + try: + for f in os.listdir(str(mount)): + if not f.endswith(".app"): + continue + app = mount / f + shutil.copytree(str(app), str(target)) + found = True + break + finally: + try: + self._run_process(f"hdiutil detach {str(mount)}".split()) + except subprocess.CalledProcessError as e: # noqa + self.warning("Detach failed {e.stdout}") + finally: + if mount.exists(): + shutil.rmtree(str(mount)) + if not found: + self.error(f"No app file found in {dmg}") + raise IOError(dmg) + + def run(self, metadata): + # Each DMG is mounted, then we look for the .app + # directory in it, which is copied in a directory + # alongside the .dmg file. That directory + # is removed during teardown. + for option, path_in_dmg in POTENTIAL_DMGS.items(): + value = self.get_arg(option) + + if value is None or not value.endswith(".dmg"): + continue + + self.info(f"Mounting {value}") + dmg_file = Path(value) + if not dmg_file.exists(): + raise FileNotFoundError(str(dmg_file)) + + # let's unpack the DMG in place... + target = dmg_file.parent / dmg_file.name.split(".")[0] + self._tmp_dirs.append(target) + self.extract_app(dmg_file, target) + + # ... find a specific file or directory if needed ... + path = target / path_in_dmg + if not path.exists(): + raise FileNotFoundError(str(path)) + + # ... and swap the browsertime argument + self.info(f"Using {path} for {option}") + self.env.set_arg(option, str(path)) + return metadata + + def teardown(self): + for dir in self._tmp_dirs: + if dir.exists(): + shutil.rmtree(str(dir)) diff --git a/python/mozperftest/mozperftest/system/pingserver.py b/python/mozperftest/mozperftest/system/pingserver.py new file mode 100644 index 0000000000..4ae6b9a113 --- /dev/null +++ b/python/mozperftest/mozperftest/system/pingserver.py @@ -0,0 +1,94 @@ +# 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 +import socketserver +import threading +import time +from pathlib import Path + +from mozlog import get_proxy_logger + +from mozperftest.layers import Layer +from mozperftest.utils import install_package + +LOG = get_proxy_logger(component="proxy") +HERE = os.path.dirname(__file__) + + +class PingServer(Layer): + """Runs the edgeping layer""" + + name = "pingserver" + activated = False + + arguments = {} + + def setup(self): + # Install edgeping and requests + deps = ["edgeping==0.1", "requests==2.9.1"] + for dep in deps: + install_package(self.mach_cmd.virtualenv_manager, dep) + + def _wait_for_server(self, endpoint): + import requests + + start = time.monotonic() + while True: + try: + requests.get(endpoint, timeout=0.1) + return + except Exception: + # we want to wait at most 5sec. + if time.monotonic() - start > 5.0: + raise + time.sleep(0.01) + + def run(self, metadata): + from edgeping.server import PingHandling + + self.verbose = self.get_arg("verbose") + self.metadata = metadata + self.debug("Starting the Edgeping server") + self.httpd = socketserver.TCPServer(("localhost", 0), PingHandling) + self.server_thread = threading.Thread(target=self.httpd.serve_forever) + # the chosen socket gets picked in the constructor so we can grab it here + address = self.httpd.server_address + self.endpoint = f"http://{address[0]}:{address[1]}" + self.server_thread.start() + self._wait_for_server(self.endpoint + "/status") + + self.debug(f"Edgeping coserver running at {self.endpoint}") + prefs = { + "toolkit.telemetry.server": self.endpoint, + "telemetry.fog.test.localhost_port": address[1], + "datareporting.healthreport.uploadEnabled": True, + "datareporting.policy.dataSubmissionEnabled": True, + "toolkit.telemetry.enabled": True, + "toolkit.telemetry.unified": True, + "toolkit.telemetry.shutdownPingSender.enabled": True, + "datareporting.policy.dataSubmissionPolicyBypassNotification": True, + "toolkit.telemetry.send.overrideOfficialCheck": True, + } + if self.verbose: + prefs["toolkit.telemetry.log.level"] = "Trace" + prefs["toolkit.telemetry.log.dump"] = True + + browser_prefs = metadata.get_options("browser_prefs") + browser_prefs.update(prefs) + return metadata + + def teardown(self): + import requests + + self.info("Grabbing the pings") + pings = requests.get(f"{self.endpoint}/pings").json() + output = Path(self.get_arg("output"), "telemetry.json") + self.info(f"Writing in {output}") + with output.open("w") as f: + f.write(json.dumps(pings)) + + self.debug("Stopping the Edgeping coserver") + self.httpd.shutdown() + self.server_thread.join() diff --git a/python/mozperftest/mozperftest/system/profile.py b/python/mozperftest/mozperftest/system/profile.py new file mode 100644 index 0000000000..d29744a818 --- /dev/null +++ b/python/mozperftest/mozperftest/system/profile.py @@ -0,0 +1,122 @@ +# 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 shutil +import tempfile +from pathlib import Path + +from condprof.client import ProfileNotFoundError, get_profile +from condprof.util import get_current_platform +from mozprofile import create_profile +from mozprofile.prefs import Preferences + +from mozperftest.layers import Layer + +HERE = os.path.dirname(__file__) + + +class Profile(Layer): + name = "profile" + activated = True + arguments = { + "directory": {"type": str, "default": None, "help": "Profile to use"}, + "user-js": {"type": str, "default": None, "help": "Custom user.js"}, + "conditioned": { + "action": "store_true", + "default": False, + "help": "Use a conditioned profile.", + }, + "conditioned-scenario": { + "type": str, + "default": "settled", + "help": "Conditioned scenario to use", + }, + "conditioned-platform": { + "type": str, + "default": None, + "help": "Conditioned platform to use (use local by default)", + }, + "conditioned-project": { + "type": str, + "default": "mozilla-central", + "help": "Conditioned project", + "choices": ["try", "mozilla-central"], + }, + } + + def __init__(self, env, mach_cmd): + super(Profile, self).__init__(env, mach_cmd) + self._created_dirs = [] + + def setup(self): + pass + + def _cleanup(self): + pass + + def _get_conditioned_profile(self): + platform = self.get_arg("conditioned-platform") + if platform is None: + platform = get_current_platform() + scenario = self.get_arg("conditioned-scenario") + project = self.get_arg("conditioned-project") + alternate_project = "mozilla-central" if project != "mozilla-central" else "try" + + temp_dir = tempfile.mkdtemp() + try: + condprof = get_profile(temp_dir, platform, scenario, repo=project) + except ProfileNotFoundError: + condprof = get_profile(temp_dir, platform, scenario, repo=alternate_project) + except Exception: + raise + + # now get the full directory path to our fetched conditioned profile + condprof = Path(temp_dir, condprof) + if not condprof.exists(): + raise OSError(str(condprof)) + + return condprof + + def run(self, metadata): + # using a conditioned profile + if self.get_arg("conditioned"): + profile_dir = self._get_conditioned_profile() + self.set_arg("profile-directory", str(profile_dir)) + self._created_dirs.append(str(profile_dir)) + return metadata + + if self.get_arg("directory") is not None: + # no need to create one or load a conditioned one + return metadata + + # fresh profile + profile = create_profile(app="firefox") + + # mozprofile.Profile.__del__ silently deletes the profile + # it creates in a non-deterministic time (garbage collected) by + # calling cleanup. We override this silly behavior here. + profile.cleanup = self._cleanup + + prefs = metadata.get_options("browser_prefs") + + if prefs == {}: + prefs["mozperftest"] = "true" + + # apply custom user prefs if any + user_js = self.get_arg("user-js") + if user_js is not None: + self.info("Applying use prefs from %s" % user_js) + default_prefs = dict(Preferences.read_prefs(user_js)) + prefs.update(default_prefs) + + profile.set_preferences(prefs) + self.info("Created profile at %s" % profile.profile) + self._created_dirs.append(profile.profile) + self.set_arg("profile-directory", profile.profile) + return metadata + + def teardown(self): + for dir in self._created_dirs: + if os.path.exists(dir): + shutil.rmtree(dir) diff --git a/python/mozperftest/mozperftest/system/proxy.py b/python/mozperftest/mozperftest/system/proxy.py new file mode 100644 index 0000000000..d43a65a0eb --- /dev/null +++ b/python/mozperftest/mozperftest/system/proxy.py @@ -0,0 +1,232 @@ +# 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 +import pathlib +import re +import signal +import tempfile +import threading + +from mozdevice import ADBDevice +from mozlog import get_proxy_logger +from mozprocess import ProcessHandler + +from mozperftest.layers import Layer +from mozperftest.utils import ON_TRY, download_file, get_output_dir, install_package + +LOG = get_proxy_logger(component="proxy") +HERE = os.path.dirname(__file__) + + +class OutputHandler(object): + def __init__(self): + self.proc = None + self.port = None + self.port_event = threading.Event() + + def __call__(self, line): + line = line.strip() + if not line: + return + line = line.decode("utf-8", errors="replace") + try: + data = json.loads(line) + except ValueError: + self.process_output(line) + return + + if isinstance(data, dict) and "action" in data: + # Retrieve the port number for the proxy server from the logs of + # our subprocess. + m = re.match(r"Proxy running on port (\d+)", data.get("message", "")) + if m: + self.port = int(m.group(1)) + self.port_event.set() + LOG.log_raw(data) + else: + self.process_output(json.dumps(data)) + + def finished(self): + self.port_event.set() + + def process_output(self, line): + if self.proc is None: + LOG.process_output(line) + else: + LOG.process_output(self.proc.pid, line) + + def wait_for_port(self): + self.port_event.wait() + return self.port + + +class ProxyRunner(Layer): + """Use a proxy""" + + name = "proxy" + activated = False + + arguments = { + "mode": { + "type": str, + "choices": ["record", "playback"], + "help": "Proxy server mode. Use `playback` to replay from the provided file(s). " + "Use `record` to generate a new recording at the path specified by `--file`. " + "playback - replay from provided file. " + "record - generate a new recording at the specified path.", + }, + "file": { + "type": str, + "nargs": "+", + "help": "The playback files to replay, or the file that a recording will be saved to. " + "For playback, it can be any combination of the following: zip file, manifest file, " + "or a URL to zip/manifest file. " + "For recording, it's a zip fle.", + }, + "perftest-page": { + "type": str, + "default": None, + "help": "This option can be used to specify a single test to record rather than " + "having to continuously modify the pageload_sites.json. This flag should only be " + "used by the perftest team and selects items from " + "`testing/performance/pageload_sites.json` based on the name field. Note that " + "the login fields won't be checked with a request such as this (i.e. it overrides " + "those settings).", + }, + } + + def __init__(self, env, mach_cmd): + super(ProxyRunner, self).__init__(env, mach_cmd) + self.proxy = None + self.tmpdir = None + + def setup(self): + try: + import mozproxy # noqa: F401 + except ImportError: + # Install mozproxy and its vendored deps. + mozbase = pathlib.Path(self.mach_cmd.topsrcdir, "testing", "mozbase") + mozproxy_deps = ["mozinfo", "mozlog", "mozproxy"] + for i in mozproxy_deps: + install_package( + self.mach_cmd.virtualenv_manager, pathlib.Path(mozbase, i) + ) + + # set MOZ_HOST_BIN to find cerutil. Required to set certifcates on android + os.environ["MOZ_HOST_BIN"] = self.mach_cmd.bindir + + def run(self, metadata): + self.metadata = metadata + replay_file = self.get_arg("file") + + # Check if we have a replay file + if replay_file is None: + raise ValueError("Proxy file not provided!!") + + if replay_file is not None and replay_file.startswith("http"): + self.tmpdir = tempfile.TemporaryDirectory() + target = pathlib.Path(self.tmpdir.name, "recording.zip") + self.info("Downloading %s" % replay_file) + download_file(replay_file, target) + replay_file = target + + self.info("Setting up the proxy") + + command = [ + self.mach_cmd.virtualenv_manager.python_path, + "-m", + "mozproxy.driver", + "--topsrcdir=" + self.mach_cmd.topsrcdir, + "--objdir=" + self.mach_cmd.topobjdir, + "--profiledir=" + self.get_arg("profile-directory"), + ] + + if not ON_TRY: + command.extend(["--local"]) + + if metadata.flavor == "mobile-browser": + command.extend(["--tool=%s" % "mitmproxy-android"]) + command.extend(["--binary=android"]) + else: + command.extend(["--tool=%s" % "mitmproxy"]) + # XXX See bug 1712337, we need a single point where we can get the binary used from + # this is required to make it work localy + binary = self.get_arg("browsertime-binary") + if binary is None: + binary = self.mach_cmd.get_binary_path() + command.extend(["--binary=%s" % binary]) + + if self.get_arg("mode") == "record": + output = self.get_arg("output") + if output is None: + output = pathlib.Path(self.mach_cmd.topsrcdir, "artifacts") + results_dir = get_output_dir(output) + + command.extend(["--mode", "record"]) + command.append(str(pathlib.Path(results_dir, replay_file))) + elif self.get_arg("mode") == "playback": + command.extend(["--mode", "playback"]) + command.append(str(replay_file)) + else: + raise ValueError("Proxy mode not provided please provide proxy mode") + + inject_deterministic = self.get_arg("deterministic") + if inject_deterministic: + command.extend(["--deterministic"]) + + print(" ".join(command)) + self.output_handler = OutputHandler() + self.proxy = ProcessHandler( + command, + processOutputLine=self.output_handler, + onFinish=self.output_handler.finished, + ) + self.output_handler.proc = self.proxy + self.proxy.run() + + # Wait until we've retrieved the proxy server's port number so we can + # configure the browser properly. + port = self.output_handler.wait_for_port() + if port is None: + raise ValueError("Unable to retrieve the port number from mozproxy") + self.info("Received port number %s from mozproxy" % port) + + prefs = { + "network.proxy.type": 1, + "network.proxy.http": "127.0.0.1", + "network.proxy.http_port": port, + "network.proxy.ssl": "127.0.0.1", + "network.proxy.ssl_port": port, + "network.proxy.no_proxies_on": "127.0.0.1", + } + browser_prefs = metadata.get_options("browser_prefs") + browser_prefs.update(prefs) + + if metadata.flavor == "mobile-browser": + self.info("Setting reverse port fw for android device") + device = ADBDevice() + device.create_socket_connection("reverse", "tcp:%s" % port, "tcp:%s" % port) + + return metadata + + def teardown(self): + err = None + if self.proxy is not None: + returncode = self.proxy.wait(0) + if returncode is not None: + err = ValueError( + "mozproxy terminated early with return code %d" % returncode + ) + else: + kill_signal = getattr(signal, "CTRL_BREAK_EVENT", signal.SIGINT) + os.kill(self.proxy.pid, kill_signal) + self.proxy.wait() + self.proxy = None + if self.tmpdir is not None: + self.tmpdir.cleanup() + self.tmpdir = None + + if err: + raise err |