294 lines
9.9 KiB
Python
294 lines
9.9 KiB
Python
# 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/.
|
|
""" Drives an android device.
|
|
"""
|
|
import os
|
|
import posixpath
|
|
import tempfile
|
|
import contextlib
|
|
import time
|
|
import logging
|
|
|
|
import attr
|
|
from arsenic.services import Geckodriver, free_port, subprocess_based_service
|
|
from mozdevice import ADBDeviceFactory, ADBError
|
|
|
|
from condprof.util import write_yml_file, logger, DEFAULT_PREFS, BaseEnv
|
|
|
|
|
|
# XXX most of this code should migrate into mozdevice - see Bug 1574849
|
|
class AndroidDevice:
|
|
def __init__(
|
|
self,
|
|
app_name,
|
|
marionette_port=2828,
|
|
verbose=False,
|
|
remote_test_root="/sdcard/test_root/",
|
|
):
|
|
self.app_name = app_name
|
|
|
|
# XXX make that an option
|
|
if "fenix" in app_name:
|
|
self.activity = "org.mozilla.fenix.IntentReceiverActivity"
|
|
else:
|
|
self.activity = "org.mozilla.geckoview_example.GeckoViewActivity"
|
|
self.verbose = verbose
|
|
self.device = None
|
|
self.marionette_port = marionette_port
|
|
self.profile = None
|
|
self.remote_profile = None
|
|
self.log_file = None
|
|
self.remote_test_root = remote_test_root
|
|
self._adb_fh = None
|
|
|
|
def _set_adb_logger(self, log_file):
|
|
self.log_file = log_file
|
|
if self.log_file is None:
|
|
return
|
|
logger.info("Setting ADB log file to %s" % self.log_file)
|
|
adb_logger = logging.getLogger("adb")
|
|
adb_logger.setLevel(logging.DEBUG)
|
|
self._adb_fh = logging.FileHandler(self.log_file)
|
|
self._adb_fh.setLevel(logging.DEBUG)
|
|
adb_logger.addHandler(self._adb_fh)
|
|
|
|
def _unset_adb_logger(self):
|
|
if self._adb_fh is None:
|
|
return
|
|
logging.getLogger("adb").removeHandler(self._adb_fh)
|
|
self._adb_fh = None
|
|
|
|
def clear_logcat(self, timeout=None, buffers=[]):
|
|
if not self.device:
|
|
return
|
|
self.device.clear_logcat(timeout, buffers)
|
|
|
|
def get_logcat(self):
|
|
if not self.device:
|
|
return None
|
|
# we don't want to have ADBCommand dump the command
|
|
# in the debug stream so we reduce its verbosity here
|
|
# temporarely
|
|
old_verbose = self.device._verbose
|
|
self.device._verbose = False
|
|
try:
|
|
return self.device.get_logcat()
|
|
finally:
|
|
self.device._verbose = old_verbose
|
|
|
|
def prepare(self, profile, logfile):
|
|
self._set_adb_logger(logfile)
|
|
try:
|
|
# See android_emulator_pgo.py run_tests for more
|
|
# details on why test_root must be /sdcard/test_root
|
|
# for android pgo due to Android 4.3.
|
|
self.device = ADBDeviceFactory(
|
|
verbose=self.verbose, logger_name="adb", test_root=self.remote_test_root
|
|
)
|
|
except Exception:
|
|
logger.error("Cannot initialize device")
|
|
raise
|
|
device = self.device
|
|
self.profile = profile
|
|
|
|
# checking that the app is installed
|
|
if not device.is_app_installed(self.app_name):
|
|
raise Exception("%s is not installed" % self.app_name)
|
|
|
|
# debug flag
|
|
logger.info("Setting %s as the debug app on the phone" % self.app_name)
|
|
device.shell(
|
|
"am set-debug-app --persistent %s" % self.app_name,
|
|
stdout_callback=logger.info,
|
|
)
|
|
|
|
# creating the profile on the device
|
|
logger.info("Creating the profile on the device")
|
|
|
|
remote_profile = posixpath.join(self.remote_test_root, "profile")
|
|
logger.info("The profile on the phone will be at %s" % remote_profile)
|
|
|
|
device.rm(remote_profile, force=True, recursive=True)
|
|
logger.info("Pushing %s on the phone" % self.profile)
|
|
device.push(profile, remote_profile)
|
|
device.chmod(remote_profile, recursive=True)
|
|
self.profile = profile
|
|
self.remote_profile = remote_profile
|
|
|
|
# creating the yml file
|
|
yml_data = {
|
|
"args": ["-marionette", "-profile", self.remote_profile],
|
|
"prefs": DEFAULT_PREFS,
|
|
"env": {"LOG_VERBOSE": 1, "R_LOG_LEVEL": 6, "MOZ_LOG": ""},
|
|
}
|
|
|
|
yml_name = "%s-geckoview-config.yaml" % self.app_name
|
|
yml_on_host = posixpath.join(tempfile.mkdtemp(), yml_name)
|
|
write_yml_file(yml_on_host, yml_data)
|
|
tmp_on_device = posixpath.join("/data", "local", "tmp")
|
|
if not device.exists(tmp_on_device):
|
|
raise IOError("%s does not exists on the device" % tmp_on_device)
|
|
yml_on_device = posixpath.join(tmp_on_device, yml_name)
|
|
try:
|
|
device.rm(yml_on_device, force=True, recursive=True)
|
|
device.push(yml_on_host, yml_on_device)
|
|
device.chmod(yml_on_device, recursive=True)
|
|
except Exception:
|
|
logger.info("could not create the yaml file on device. Permission issue?")
|
|
raise
|
|
|
|
# command line 'extra' args not used with geckoview apps; instead we use
|
|
# an on-device config.yml file
|
|
intent = "android.intent.action.VIEW"
|
|
device.stop_application(self.app_name)
|
|
device.launch_application(
|
|
self.app_name, self.activity, intent, extras=None, url="about:blank"
|
|
)
|
|
if not device.process_exist(self.app_name):
|
|
raise Exception("Could not start %s" % self.app_name)
|
|
|
|
logger.info("Creating socket forwarding on port %d" % self.marionette_port)
|
|
device.forward(
|
|
local="tcp:%d" % self.marionette_port,
|
|
remote="tcp:%d" % self.marionette_port,
|
|
)
|
|
|
|
# we don't have a clean way for now to check that GV or Fenix
|
|
# is ready to handle our tests. So here we just wait 30s
|
|
logger.info("Sleeping for 30s")
|
|
time.sleep(30)
|
|
|
|
def stop_browser(self):
|
|
logger.info("Stopping %s" % self.app_name)
|
|
try:
|
|
self.device.stop_application(self.app_name)
|
|
except ADBError:
|
|
logger.info("Could not stop the application using force-stop")
|
|
|
|
time.sleep(5)
|
|
if self.device.process_exist(self.app_name):
|
|
logger.info("%s still running, trying SIGKILL" % self.app_name)
|
|
num_tries = 0
|
|
while self.device.process_exist(self.app_name) and num_tries < 5:
|
|
try:
|
|
self.device.pkill(self.app_name)
|
|
except ADBError:
|
|
pass
|
|
num_tries += 1
|
|
time.sleep(1)
|
|
logger.info("%s stopped" % self.app_name)
|
|
|
|
def collect_profile(self):
|
|
logger.info("Collecting profile from %s" % self.remote_profile)
|
|
self.device.pull(self.remote_profile, self.profile)
|
|
|
|
def close(self):
|
|
self._unset_adb_logger()
|
|
if self.device is None:
|
|
return
|
|
try:
|
|
self.device.remove_forwards("tcp:%d" % self.marionette_port)
|
|
except ADBError:
|
|
logger.warning("Could not remove forward port")
|
|
|
|
|
|
# XXX redundant, remove
|
|
@contextlib.contextmanager
|
|
def device(
|
|
app_name, marionette_port=2828, verbose=True, remote_test_root="/sdcard/test_root/"
|
|
):
|
|
device_ = AndroidDevice(
|
|
app_name, marionette_port, verbose, remote_test_root=remote_test_root
|
|
)
|
|
try:
|
|
yield device_
|
|
finally:
|
|
device_.close()
|
|
|
|
|
|
@attr.s
|
|
class AndroidGeckodriver(Geckodriver):
|
|
async def start(self):
|
|
port = free_port()
|
|
await self._check_version()
|
|
logger.info("Running Webdriver on port %d" % port)
|
|
logger.info("Running Marionette on port 2828")
|
|
pargs = [
|
|
self.binary,
|
|
"--log",
|
|
"trace",
|
|
"--port",
|
|
str(port),
|
|
"--marionette-port",
|
|
"2828",
|
|
]
|
|
logger.info("Connecting on Android device")
|
|
pargs.append("--connect-existing")
|
|
return await subprocess_based_service(
|
|
pargs, f"http://localhost:{port}", self.log_file
|
|
)
|
|
|
|
|
|
class AndroidEnv(BaseEnv):
|
|
@contextlib.contextmanager
|
|
def get_device(self, *args, **kw):
|
|
with device(self.firefox, *args, **kw) as d:
|
|
self.device = d
|
|
yield self.device
|
|
|
|
def get_target_platform(self):
|
|
app = self.firefox.split("org.mozilla.")[-1]
|
|
if self.device_name is None:
|
|
return app
|
|
return "%s-%s" % (self.device_name, app)
|
|
|
|
def dump_logs(self):
|
|
logger.info("Dumping Android logs")
|
|
try:
|
|
logcat = self.device.get_logcat()
|
|
if logcat:
|
|
# local path, not using posixpath
|
|
logfile = os.path.join(self.archive, "logcat.log")
|
|
logger.info("Writing logcat at %s" % logfile)
|
|
with open(logfile, "wb") as f:
|
|
for line in logcat:
|
|
f.write(line.encode("utf8", errors="replace") + b"\n")
|
|
else:
|
|
logger.info("logcat came back empty")
|
|
except Exception:
|
|
logger.error("Could not extract the logcat", exc_info=True)
|
|
|
|
@contextlib.contextmanager
|
|
def get_browser(self):
|
|
yield
|
|
|
|
def get_browser_args(self, headless, prefs=None):
|
|
# XXX merge with DesktopEnv.get_browser_args
|
|
options = ["--allow-downgrade"]
|
|
if headless:
|
|
options.append("-headless")
|
|
if prefs is None:
|
|
prefs = {}
|
|
return {"moz:firefoxOptions": {"args": options, "prefs": prefs}}
|
|
|
|
def prepare(self, logfile):
|
|
self.device.prepare(self.profile, logfile)
|
|
|
|
def get_browser_version(self):
|
|
return self.target_platform + "-XXXneedtograbversion"
|
|
|
|
def get_geckodriver(self, log_file):
|
|
return AndroidGeckodriver(binary=self.geckodriver, log_file=log_file)
|
|
|
|
def check_session(self, session):
|
|
async def fake_close(*args):
|
|
pass
|
|
|
|
session.close = fake_close
|
|
|
|
def collect_profile(self):
|
|
self.device.collect_profile()
|
|
|
|
def stop_browser(self):
|
|
self.device.stop_browser()
|