[Gecko Bug 1323620] Add "fennec" product to wptrunner. (#11530)

This allows running web-platform-tests on Fennec given a running emulator.
(Which is how we expect the tests to run in automation as well -- the
android_emulator_unittest mozharness script takes care of emulator
start-up.) It also hooks up ./mach wpt.

wptrunner sets up a profile for Fennec, forwards the marionette port
and starts up Fennec, etc.

= Usage =

Set your mozconfig to build fennec.

Start an emulator: `./mach android-emulator --version x86`
Install fennec: `./mach build && ./mach package && ./mach install`
Run the tests:

```
./mach wpt --product=fennec --testtype=testharness
--certutil-binary path/to/host/os/certutil path/to/some/tests
```

Differential Revision: https://phabricator.services.mozilla.com/D1587

bugzilla-url: https://bugzilla.mozilla.org/show_bug.cgi?id=1323620
gecko-commit: 266c6cfb96a76daaa7bd2e3574f7d1d8ae60c4a6
gecko-integration-branch: autoland
gecko-reviewers: jgraham
diff --git a/tools/wpt/browser.py b/tools/wpt/browser.py
index c350946..fd495fd 100644
--- a/tools/wpt/browser.py
+++ b/tools/wpt/browser.py
@@ -283,6 +283,28 @@
         return m.group(1)
 
 
+class Fennec(Browser):
+    """Fennec-specific interface."""
+
+    product = "fennec"
+    requirements = "requirements_firefox.txt"
+
+    def install(self, dest=None):
+        raise NotImplementedError
+
+    def find_binary(self, venv_path=None):
+        raise NotImplementedError
+
+    def find_webdriver(self):
+        raise NotImplementedError
+
+    def install_webdriver(self, dest=None):
+        raise NotImplementedError
+
+    def version(self, binary=None):
+        return None
+
+
 class Chrome(Browser):
     """Chrome-specific interface.
 
diff --git a/tools/wpt/run.py b/tools/wpt/run.py
index b51407f..ed5c56f 100644
--- a/tools/wpt/run.py
+++ b/tools/wpt/run.py
@@ -204,6 +204,14 @@
             kwargs["prefs_root"] = prefs_root
 
 
+class Fennec(BrowserSetup):
+    name = "fennec"
+    browser_cls = browser.Fennec
+
+    def setup_kwargs(self, kwargs):
+        pass
+
+
 class Chrome(BrowserSetup):
     name = "chrome"
     browser_cls = browser.Chrome
@@ -374,6 +382,7 @@
 
 
 product_setup = {
+    "fennec": Fennec,
     "firefox": Firefox,
     "chrome": Chrome,
     "chrome_android": ChromeAndroid,
diff --git a/tools/wptrunner/wptrunner/browsers/__init__.py b/tools/wptrunner/wptrunner/browsers/__init__.py
index 6f0c49e..d8682e1 100644
--- a/tools/wptrunner/wptrunner/browsers/__init__.py
+++ b/tools/wptrunner/wptrunner/browsers/__init__.py
@@ -6,7 +6,7 @@
 "browser": String indicating the Browser implementation used to launch that
            product.
 "executor": Dictionary with keys as supported test types and values as the name
-            of the Executor implemantation that will be used to run that test
+            of the Executor implementation that will be used to run that test
             type.
 "browser_kwargs": String naming function that takes product, binary,
                   prefs_root and the wptrunner.run_tests kwargs dict as arguments
@@ -25,6 +25,7 @@
 product_list = ["chrome",
                 "chrome_android",
                 "edge",
+                "fennec",
                 "firefox",
                 "ie",
                 "safari",
diff --git a/tools/wptrunner/wptrunner/browsers/fennec.py b/tools/wptrunner/wptrunner/browsers/fennec.py
new file mode 100644
index 0000000..8818760
--- /dev/null
+++ b/tools/wptrunner/wptrunner/browsers/fennec.py
@@ -0,0 +1,254 @@
+import os
+import signal
+import sys
+import tempfile
+import traceback
+
+import moznetwork
+from mozprocess import ProcessHandler
+from mozprofile import FirefoxProfile
+from mozrunner import FennecEmulatorRunner
+
+from serve.serve import make_hosts_file
+
+from .base import (get_free_port,
+                   cmd_arg,
+                   browser_command)
+from ..executors.executormarionette import MarionetteTestharnessExecutor  # noqa: F401
+from .firefox import (get_timeout_multiplier, update_properties, executor_kwargs, FirefoxBrowser)  # noqa: F401
+
+
+__wptrunner__ = {"product": "fennec",
+                 "check_args": "check_args",
+                 "browser": "FennecBrowser",
+                 "executor": {"testharness": "MarionetteTestharnessExecutor"},
+                 "browser_kwargs": "browser_kwargs",
+                 "executor_kwargs": "executor_kwargs",
+                 "env_extras": "env_extras",
+                 "env_options": "env_options",
+                 "run_info_extras": "run_info_extras",
+                 "update_properties": "update_properties"}
+
+class FennecProfile(FirefoxProfile):
+    # WPT-specific prefs are set in FennecBrowser.start()
+    FirefoxProfile.preferences.update({
+        # Make sure Shield doesn't hit the network.
+        "app.normandy.api_url": "",
+        # Increase the APZ content response timeout in tests to 1 minute.
+        "apz.content_response_timeout": 60000,
+        # Enable output of dump()
+        "browser.dom.window.dump.enabled": True,
+        # Disable safebrowsing components
+        "browser.safebrowsing.blockedURIs.enabled": False,
+        "browser.safebrowsing.downloads.enabled": False,
+        "browser.safebrowsing.passwords.enabled": False,
+        "browser.safebrowsing.malware.enabled": False,
+        "browser.safebrowsing.phishing.enabled": False,
+        # Do not restore the last open set of tabs if the browser has crashed
+        "browser.sessionstore.resume_from_crash": False,
+        # Disable Android snippets
+        "browser.snippets.enabled": False,
+        "browser.snippets.syncPromo.enabled": False,
+        "browser.snippets.firstrunHomepage.enabled": False,
+        # Do not allow background tabs to be zombified, otherwise for tests that
+        # open additional tabs, the test harness tab itself might get unloaded
+        "browser.tabs.disableBackgroundZombification": True,
+        # Disable e10s by default
+        "browser.tabs.remote.autostart": False,
+        # Don't warn when exiting the browser
+        "browser.warnOnQuit": False,
+        # Don't send Firefox health reports to the production server
+        "datareporting.healthreport.about.reportUrl": "http://%(server)s/dummy/abouthealthreport/",
+        # Automatically unload beforeunload alerts
+        "dom.disable_beforeunload": True,
+        # Disable the ProcessHangMonitor
+        "dom.ipc.reportProcessHangs": False,
+        # No slow script dialogs
+        "dom.max_chrome_script_run_time": 0,
+        "dom.max_script_run_time": 0,
+        # Make sure opening about:addons won"t hit the network
+        "extensions.webservice.discoverURL": "http://%(server)s/dummy/discoveryURL",
+        # No hang monitor
+        "hangmonitor.timeout": 0,
+
+        "javascript.options.showInConsole": True,
+        # Ensure blocklist updates don't hit the network
+        "services.settings.server": "http://%(server)s/dummy/blocklist/",
+        # Disable password capture, so that tests that include forms aren"t
+        # influenced by the presence of the persistent doorhanger notification
+        "signon.rememberSignons": False,
+    })
+
+
+def check_args(**kwargs):
+    pass
+
+def browser_kwargs(test_type, run_info_data, **kwargs):
+    return {"package_name": kwargs["package_name"],
+            "device_serial": kwargs["device_serial"],
+            "prefs_root": kwargs["prefs_root"],
+            "extra_prefs": kwargs["extra_prefs"],
+            "test_type": test_type,
+            "debug_info": kwargs["debug_info"],
+            "symbols_path": kwargs["symbols_path"],
+            "stackwalk_binary": kwargs["stackwalk_binary"],
+            "certutil_binary": kwargs["certutil_binary"],
+            "ca_certificate_path": kwargs["ssl_env"].ca_cert_path(),
+            "stackfix_dir": kwargs["stackfix_dir"],
+            "binary_args": kwargs["binary_args"],
+            "timeout_multiplier": get_timeout_multiplier(test_type,
+                                                         run_info_data,
+                                                         **kwargs),
+            "leak_check": kwargs["leak_check"],
+            "stylo_threads": kwargs["stylo_threads"],
+            "chaos_mode_flags": kwargs["chaos_mode_flags"],
+            "config": kwargs["config"]}
+
+
+def env_extras(**kwargs):
+    return []
+
+
+def run_info_extras(**kwargs):
+    return {"e10s": False,
+            "headless": False}
+
+
+def env_options():
+    # The server host is set to public localhost IP so that resources can be accessed
+    # from Android emulator
+    return {"server_host": moznetwork.get_ip(),
+            "bind_address": False,
+            "supports_debugger": True}
+
+
+def write_hosts_file(config, device):
+    new_hosts = make_hosts_file(config, moznetwork.get_ip())
+    current_hosts = device.get_file("/etc/hosts")
+    if new_hosts == current_hosts:
+        return
+    hosts_fd, hosts_path = tempfile.mkstemp()
+    try:
+        with os.fdopen(hosts_fd, "w") as f:
+            f.write(new_hosts)
+        device.remount()
+        device.push(hosts_path, "/etc/hosts")
+    finally:
+        os.remove(hosts_path)
+
+
+class FennecBrowser(FirefoxBrowser):
+    used_ports = set()
+    init_timeout = 300
+    shutdown_timeout = 60
+
+    def __init__(self, logger, prefs_root, test_type, package_name=None,
+                 device_serial="emulator-5444", **kwargs):
+        FirefoxBrowser.__init__(self, logger, None, prefs_root, test_type, **kwargs)
+        self._package_name = package_name
+        self.device_serial = device_serial
+
+    @property
+    def package_name(self):
+        """
+        Name of app to run on emulator.
+        """
+        if self._package_name is None:
+            self._package_name = "org.mozilla.fennec"
+            user = os.getenv("USER")
+            if user:
+                self._package_name += "_" + user
+        return self._package_name
+
+    def start(self, **kwargs):
+        if self.marionette_port is None:
+            self.marionette_port = get_free_port(2828, exclude=self.used_ports)
+            self.used_ports.add(self.marionette_port)
+
+        env = {}
+        env["MOZ_CRASHREPORTER"] = "1"
+        env["MOZ_CRASHREPORTER_SHUTDOWN"] = "1"
+        env["MOZ_DISABLE_NONLOCAL_CONNECTIONS"] = "1"
+        env["STYLO_THREADS"] = str(self.stylo_threads)
+        if self.chaos_mode_flags is not None:
+            env["MOZ_CHAOSMODE"] = str(self.chaos_mode_flags)
+
+        preferences = self.load_prefs()
+
+        self.profile = FennecProfile(preferences=preferences)
+        self.profile.set_preferences({"marionette.port": self.marionette_port,
+                                      "dom.disable_open_during_load": False,
+                                      "places.history.enabled": False,
+                                      "dom.send_after_paint_to_content": True,
+                                      "network.preload": True})
+
+        if self.leak_check and kwargs.get("check_leaks", True):
+            self.leak_report_file = os.path.join(self.profile.profile, "runtests_leaks.log")
+            if os.path.exists(self.leak_report_file):
+                os.remove(self.leak_report_file)
+            env["XPCOM_MEM_BLOAT_LOG"] = self.leak_report_file
+        else:
+            self.leak_report_file = None
+
+        if self.ca_certificate_path is not None:
+            self.setup_ssl()
+
+        debug_args, cmd = browser_command(self.package_name,
+                                          self.binary_args if self.binary_args else [] +
+                                          [cmd_arg("marionette"), "about:blank"],
+                                          self.debug_info)
+
+        self.runner = FennecEmulatorRunner(app=self.package_name,
+                                           profile=self.profile,
+                                           cmdargs=cmd[1:],
+                                           env=env,
+                                           symbols_path=self.symbols_path,
+                                           serial=self.device_serial,
+                                           # TODO - choose appropriate log dir
+                                           logdir=os.getcwd(),
+                                           process_class=ProcessHandler,
+                                           process_args={"processOutputLine": [self.on_output]})
+
+        self.logger.debug("Starting Fennec")
+        # connect to a running emulator
+        self.runner.device.connect()
+
+        write_hosts_file(self.config, self.runner.device.device)
+
+        self.runner.start(debug_args=debug_args, interactive=self.debug_info and self.debug_info.interactive)
+
+        # gecko_log comes from logcat when running with device/emulator
+        logcat_args = {
+            "filterspec": "Gecko",
+            "serial": self.runner.device.app_ctx.device_serial
+        }
+        # TODO setting logcat_args["logfile"] yields an almost empty file
+        # even without filterspec
+        logcat_args["stream"] = sys.stdout
+        self.runner.device.start_logcat(**logcat_args)
+
+        self.runner.device.device.forward(
+            local="tcp:{}".format(self.marionette_port),
+            remote="tcp:{}".format(self.marionette_port))
+
+        self.logger.debug("Fennec Started")
+
+    def stop(self, force=False):
+        if self.runner is not None:
+            try:
+                if self.runner.device.connected:
+                    self.runner.device.device.remove_forwards(
+                        "tcp:{}".format(self.marionette_port))
+            except Exception:
+                traceback.print_exception(*sys.exc_info())
+            # We assume that stopping the runner prompts the
+            # browser to shut down. This allows the leak log to be written
+            for clean, stop_f in [(True, lambda: self.runner.wait(self.shutdown_timeout)),
+                                  (False, lambda: self.runner.stop(signal.SIGTERM)),
+                                  (False, lambda: self.runner.stop(signal.SIGKILL))]:
+                if not force or not clean:
+                    retcode = stop_f()
+                    if retcode is not None:
+                        self.logger.info("Browser exited with return code %s" % retcode)
+                        break
+        self.logger.debug("stopped")
diff --git a/tools/wptrunner/wptrunner/browsers/firefox.py b/tools/wptrunner/wptrunner/browsers/firefox.py
index 9f57bb4..1707f67 100644
--- a/tools/wptrunner/wptrunner/browsers/firefox.py
+++ b/tools/wptrunner/wptrunner/browsers/firefox.py
@@ -54,6 +54,8 @@
             return 4
         else:
             return 3
+    elif run_info_data["os"] == "android":
+        return 4
     return 1
 
 
@@ -369,7 +371,7 @@
         # local copy of certutil
         # TODO: Maybe only set this if certutil won't launch?
         env = os.environ.copy()
-        certutil_dir = os.path.dirname(self.binary)
+        certutil_dir = os.path.dirname(self.binary or self.certutil_binary)
         if mozinfo.isMac:
             env_var = "DYLD_LIBRARY_PATH"
         elif mozinfo.isUnix:
diff --git a/tools/wptrunner/wptrunner/wptcommandline.py b/tools/wptrunner/wptrunner/wptcommandline.py
index d260245..7e18a8b 100644
--- a/tools/wptrunner/wptrunner/wptcommandline.py
+++ b/tools/wptrunner/wptrunner/wptcommandline.py
@@ -169,7 +169,7 @@
 
     config_group = parser.add_argument_group("Configuration")
     config_group.add_argument("--binary", action="store",
-                              type=abs_path, help="Binary to run tests against")
+                              type=abs_path, help="Desktop binary to run tests against")
     config_group.add_argument('--binary-arg',
                               default=[], action="append", dest="binary_args",
                               help="Extra argument for the binary")
@@ -178,7 +178,10 @@
     config_group.add_argument('--webdriver-arg',
                               default=[], action="append", dest="webdriver_args",
                               help="Extra argument for the WebDriver binary")
-
+    config_group.add_argument("--package-name", action="store",
+                              help="Android package name to run tests against")
+    config_group.add_argument("--device-serial", action="store",
+                              help="Running Android instance to connect to, if not emulator-5554")
     config_group.add_argument("--metadata", action="store", type=abs_path, dest="metadata_root",
                               help="Path to root directory containing test metadata"),
     config_group.add_argument("--tests", action="store", type=abs_path, dest="tests_root",