1#!/usr/bin/env python 2 3# This Source Code Form is subject to the terms of the Mozilla Public 4# License, v. 2.0. If a copy of the MPL was not distributed with this 5# file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 7from __future__ import absolute_import 8 9import os 10import posixpath 11import shutil 12import tempfile 13import time 14 15import mozcrash 16from cpu import start_android_cpu_profiler 17from logger.logger import RaptorLogger 18from mozdevice import ADBDeviceFactory, ADBProcessError 19from performance_tuning import tune_performance 20from perftest import PerftestAndroid 21from power import ( 22 init_android_power_test, 23 finish_android_power_test, 24 enable_charging, 25 disable_charging, 26) 27from signal_handler import SignalHandlerException 28from utils import write_yml_file 29from webextension.base import WebExtension 30 31LOG = RaptorLogger(component="raptor-webext-android") 32 33 34class WebExtensionAndroid(PerftestAndroid, WebExtension): 35 def __init__(self, app, binary, activity=None, intent=None, **kwargs): 36 super(WebExtensionAndroid, self).__init__( 37 app, binary, profile_class="firefox", **kwargs 38 ) 39 40 self.config.update({"activity": activity, "intent": intent}) 41 42 self.os_baseline_data = None 43 self.power_test_time = None 44 self.screen_off_timeout = 0 45 self.screen_brightness = 127 46 self.app_launched = False 47 48 def setup_adb_device(self): 49 if self.device is None: 50 self.device = ADBDeviceFactory(verbose=True) 51 if not self.config.get("disable_perf_tuning", False): 52 tune_performance(self.device, log=LOG) 53 54 self.device.run_as_package = self.config["binary"] 55 self.remote_test_root = os.path.join(self.device.test_root, "raptor") 56 self.remote_profile = os.path.join(self.remote_test_root, "profile") 57 if self.config["power_test"]: 58 disable_charging(self.device) 59 60 LOG.info("creating remote root folder for raptor: %s" % self.remote_test_root) 61 self.device.rm(self.remote_test_root, force=True, recursive=True) 62 self.device.mkdir(self.remote_test_root, parents=True) 63 64 self.clear_app_data() 65 self.set_debug_app_flag() 66 67 def process_exists(self): 68 return self.device is not None and self.device.process_exist( 69 self.config["binary"] 70 ) 71 72 def write_android_app_config(self): 73 # geckoview supports having a local on-device config file; use this file 74 # to tell the app to use the specified browser profile, as well as other opts 75 # on-device: /data/local/tmp/com.yourcompany.yourapp-geckoview-config.yaml 76 # https://mozilla.github.io/geckoview/tutorials/automation.html#configuration-file-format 77 78 LOG.info("creating android app config.yml") 79 80 yml_config_data = dict( 81 args=[ 82 "--profile", 83 self.remote_profile, 84 "--allow-downgrade", 85 ], 86 env=dict( 87 LOG_VERBOSE=1, 88 R_LOG_LEVEL=6, 89 MOZ_WEBRENDER=int(self.config["enable_webrender"]), 90 ), 91 ) 92 93 yml_name = "%s-geckoview-config.yaml" % self.config["binary"] 94 yml_on_host = os.path.join(tempfile.mkdtemp(), yml_name) 95 write_yml_file(yml_on_host, yml_config_data) 96 yml_on_device = os.path.join("/data", "local", "tmp", yml_name) 97 98 try: 99 LOG.info("copying %s to device: %s" % (yml_on_host, yml_on_device)) 100 self.device.rm(yml_on_device, force=True, recursive=True) 101 self.device.push(yml_on_host, yml_on_device) 102 103 except Exception: 104 LOG.critical("failed to push %s to device!" % yml_on_device) 105 raise 106 107 def log_android_device_temperature(self): 108 # retrieve and log the android device temperature 109 try: 110 # use sort since cat gives I/O Error on Pixel 2 - 10. 111 thermal_zone0 = self.device.shell_output( 112 "sort /sys/class/thermal/thermal_zone0/temp" 113 ) 114 try: 115 thermal_zone0 = "%.3f" % (float(thermal_zone0) / 1000) 116 except ValueError: 117 thermal_zone0 = "Unknown" 118 except ADBProcessError: 119 thermal_zone0 = "Unknown" 120 try: 121 zone_type = self.device.shell_output( 122 "cat /sys/class/thermal/thermal_zone0/type" 123 ) 124 except ADBProcessError: 125 zone_type = "Unknown" 126 LOG.info( 127 "(thermal_zone0) device temperature: %s zone type: %s" 128 % (thermal_zone0, zone_type) 129 ) 130 131 def launch_firefox_android_app(self, test_name): 132 LOG.info("starting %s" % self.config["app"]) 133 134 try: 135 # make sure the android app is not already running 136 self.device.stop_application(self.config["binary"]) 137 138 # command line 'extra' args not used with geckoview apps; instead we use 139 # an on-device config.yml file (see write_android_app_config) 140 141 self.device.launch_application( 142 self.config["binary"], 143 self.config["activity"], 144 self.config["intent"], 145 extras=None, 146 url="about:blank", 147 fail_if_running=False, 148 ) 149 150 # Check if app has started and it's running 151 if not self.process_exists: 152 raise Exception( 153 "Error launching %s. App did not start properly!" 154 % self.config["binary"] 155 ) 156 self.app_launched = True 157 except Exception as e: 158 LOG.error("Exception launching %s" % self.config["binary"]) 159 LOG.error("Exception: %s %s" % (type(e).__name__, str(e))) 160 if self.config["power_test"]: 161 finish_android_power_test(self, test_name) 162 raise 163 164 # give our control server the device and app info 165 self.control_server.device = self.device 166 self.control_server.app_name = self.config["binary"] 167 168 def copy_cert_db(self, source_dir, target_dir): 169 # copy browser cert db (that was previously created via certutil) from source to target 170 cert_db_files = ["pkcs11.txt", "key4.db", "cert9.db"] 171 for next_file in cert_db_files: 172 _source = os.path.join(source_dir, next_file) 173 _dest = os.path.join(target_dir, next_file) 174 if os.path.exists(_source): 175 LOG.info("copying %s to %s" % (_source, _dest)) 176 shutil.copyfile(_source, _dest) 177 else: 178 LOG.critical("unable to find ssl cert db file: %s" % _source) 179 180 def run_tests(self, tests, test_names): 181 self.setup_adb_device() 182 183 return super(WebExtensionAndroid, self).run_tests(tests, test_names) 184 185 def run_test_setup(self, test): 186 super(WebExtensionAndroid, self).run_test_setup(test) 187 self.set_reverse_ports() 188 189 def run_test_teardown(self, test): 190 LOG.info("removing reverse socket connections") 191 self.device.remove_socket_connections("reverse") 192 193 super(WebExtensionAndroid, self).run_test_teardown(test) 194 195 def run_test(self, test, timeout): 196 # tests will be run warm (i.e. NO browser restart between page-cycles) 197 # unless otheriwse specified in the test INI by using 'cold = true' 198 try: 199 200 if self.config["power_test"]: 201 # gather OS baseline data 202 init_android_power_test(self) 203 LOG.info("Running OS baseline, pausing for 1 minute...") 204 time.sleep(60) 205 LOG.info("Finishing baseline...") 206 finish_android_power_test(self, "os-baseline", os_baseline=True) 207 208 # initialize for the test 209 init_android_power_test(self) 210 211 if self.config.get("cold") or test.get("cold"): 212 self.__run_test_cold(test, timeout) 213 else: 214 self.__run_test_warm(test, timeout) 215 216 except SignalHandlerException: 217 self.device.stop_application(self.config["binary"]) 218 if self.config["power_test"]: 219 enable_charging(self.device) 220 221 finally: 222 if self.config["power_test"]: 223 finish_android_power_test(self, test["name"]) 224 225 def __run_test_cold(self, test, timeout): 226 """ 227 Run the Raptor test but restart the entire browser app between page-cycles. 228 229 Note: For page-load tests, playback will only be started once - at the beginning of all 230 browser cycles, and then stopped after all cycles are finished. The proxy is set via prefs 231 in the browser profile so those will need to be set again in each new profile/cycle. 232 Note that instead of using the certutil tool each time to create a db and import the 233 mitmproxy SSL cert (it's done in mozbase/mozproxy) we will simply copy the existing 234 cert db from the first cycle's browser profile into the new clean profile; this way 235 we don't have to re-create the cert db on each browser cycle. 236 237 Since we're running in cold-mode, before this point (in manifest.py) the 238 'expected-browser-cycles' value was already set to the initial 'page-cycles' value; 239 and the 'page-cycles' value was set to 1 as we want to perform one page-cycle per 240 browser restart. 241 242 The 'browser-cycle' value is the current overall browser start iteration. The control 243 server will receive the current 'browser-cycle' and the 'expected-browser-cycles' in 244 each results set received; and will pass that on as part of the results so that the 245 results processing will know results for multiple browser cycles are being received. 246 247 The default will be to run in warm mode; unless 'cold = true' is set in the test INI. 248 """ 249 LOG.info( 250 "test %s is running in cold mode; browser WILL be restarted between " 251 "page cycles" % test["name"] 252 ) 253 254 for test["browser_cycle"] in range(1, test["expected_browser_cycles"] + 1): 255 256 LOG.info( 257 "begin browser cycle %d of %d for test %s" 258 % (test["browser_cycle"], test["expected_browser_cycles"], test["name"]) 259 ) 260 261 self.run_test_setup(test) 262 263 self.clear_app_data() 264 self.set_debug_app_flag() 265 266 if test["browser_cycle"] == 1: 267 if test.get("playback") is not None: 268 # an ssl cert db has now been created in the profile; copy it out so we 269 # can use the same cert db in future test cycles / browser restarts 270 local_cert_db_dir = tempfile.mkdtemp() 271 LOG.info( 272 "backing up browser ssl cert db that was created via certutil" 273 ) 274 self.copy_cert_db( 275 self.config["local_profile_dir"], local_cert_db_dir 276 ) 277 278 if not self.is_localhost: 279 self.delete_proxy_settings_from_profile() 280 281 else: 282 # double-check to ensure app has been shutdown 283 self.device.stop_application(self.config["binary"]) 284 285 # initial browser profile was already created before run_test was called; 286 # now additional browser cycles we want to create a new one each time 287 self.build_browser_profile() 288 289 if test.get("playback") is not None: 290 # get cert db from previous cycle profile and copy into new clean profile 291 # this saves us from having to start playback again / recreate cert db etc. 292 LOG.info("copying existing ssl cert db into new browser profile") 293 self.copy_cert_db( 294 local_cert_db_dir, self.config["local_profile_dir"] 295 ) 296 297 self.run_test_setup(test) 298 299 if test.get("playback") is not None: 300 self.turn_on_android_app_proxy() 301 302 self.copy_profile_to_device() 303 self.log_android_device_temperature() 304 305 # write android app config.yml 306 self.write_android_app_config() 307 308 # now start the browser/app under test 309 self.launch_firefox_android_app(test["name"]) 310 311 # set our control server flag to indicate we are running the browser/app 312 self.control_server._finished = False 313 314 if self.config["cpu_test"]: 315 # start measuring CPU usage 316 self.cpu_profiler = start_android_cpu_profiler(self) 317 318 self.wait_for_test_finish(test, timeout, self.process_exists) 319 320 # in debug mode, and running locally, leave the browser running 321 if self.debug_mode and self.config["run_local"]: 322 LOG.info( 323 "* debug-mode enabled - please shutdown the browser manually..." 324 ) 325 self.runner.wait(timeout=None) 326 327 # break test execution if a exception is present 328 if len(self.results_handler.page_timeout_list) > 0: 329 break 330 331 def __run_test_warm(self, test, timeout): 332 LOG.info( 333 "test %s is running in warm mode; browser will NOT be restarted between " 334 "page cycles" % test["name"] 335 ) 336 337 self.run_test_setup(test) 338 339 if not self.is_localhost: 340 self.delete_proxy_settings_from_profile() 341 342 if test.get("playback") is not None: 343 self.turn_on_android_app_proxy() 344 345 self.clear_app_data() 346 self.set_debug_app_flag() 347 self.copy_profile_to_device() 348 self.log_android_device_temperature() 349 350 # write android app config.yml 351 self.write_android_app_config() 352 353 # now start the browser/app under test 354 self.launch_firefox_android_app(test["name"]) 355 356 # set our control server flag to indicate we are running the browser/app 357 self.control_server._finished = False 358 359 if self.config["cpu_test"]: 360 # start measuring CPU usage 361 self.cpu_profiler = start_android_cpu_profiler(self) 362 363 self.wait_for_test_finish(test, timeout, self.process_exists) 364 365 # in debug mode, and running locally, leave the browser running 366 if self.debug_mode and self.config["run_local"]: 367 LOG.info("* debug-mode enabled - please shutdown the browser manually...") 368 369 def check_for_crashes(self): 370 super(WebExtensionAndroid, self).check_for_crashes() 371 372 if not self.app_launched: 373 LOG.info("skipping check_for_crashes: application has not been launched") 374 return 375 self.app_launched = False 376 377 try: 378 dump_dir = tempfile.mkdtemp() 379 remote_dir = posixpath.join(self.remote_profile, "minidumps") 380 if not self.device.is_dir(remote_dir): 381 return 382 self.device.pull(remote_dir, dump_dir) 383 self.crashes += mozcrash.log_crashes( 384 LOG, dump_dir, self.config["symbols_path"] 385 ) 386 finally: 387 try: 388 shutil.rmtree(dump_dir) 389 except Exception: 390 LOG.warning("unable to remove directory: %s" % dump_dir) 391 392 def clean_up(self): 393 LOG.info("removing test folder for raptor: %s" % self.remote_test_root) 394 self.device.rm(self.remote_test_root, force=True, recursive=True) 395 396 if self.config["power_test"]: 397 enable_charging(self.device) 398 399 super(WebExtensionAndroid, self).clean_up() 400