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