summaryrefslogtreecommitdiffstats
path: root/python/mozperftest/mozperftest/system
diff options
context:
space:
mode:
Diffstat (limited to 'python/mozperftest/mozperftest/system')
-rw-r--r--python/mozperftest/mozperftest/system/__init__.py35
-rw-r--r--python/mozperftest/mozperftest/system/android.py238
-rw-r--r--python/mozperftest/mozperftest/system/android_perf_tuner.py193
-rw-r--r--python/mozperftest/mozperftest/system/android_startup.py414
-rw-r--r--python/mozperftest/mozperftest/system/example.zipbin0 -> 6588776 bytes
-rw-r--r--python/mozperftest/mozperftest/system/macos.py120
-rw-r--r--python/mozperftest/mozperftest/system/pingserver.py94
-rw-r--r--python/mozperftest/mozperftest/system/profile.py122
-rw-r--r--python/mozperftest/mozperftest/system/proxy.py232
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
new file mode 100644
index 0000000000..8c724762d3
--- /dev/null
+++ b/python/mozperftest/mozperftest/system/example.zip
Binary files differ
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