summaryrefslogtreecommitdiffstats
path: root/testing/marionette/client/marionette_driver
diff options
context:
space:
mode:
Diffstat (limited to 'testing/marionette/client/marionette_driver')
-rw-r--r--testing/marionette/client/marionette_driver/__init__.py22
-rw-r--r--testing/marionette/client/marionette_driver/addons.py76
-rw-r--r--testing/marionette/client/marionette_driver/by.py25
-rw-r--r--testing/marionette/client/marionette_driver/date_time_value.py49
-rw-r--r--testing/marionette/client/marionette_driver/decorators.py79
-rw-r--r--testing/marionette/client/marionette_driver/errors.py206
-rw-r--r--testing/marionette/client/marionette_driver/expected.py315
-rw-r--r--testing/marionette/client/marionette_driver/geckoinstance.py666
-rw-r--r--testing/marionette/client/marionette_driver/keys.py88
-rw-r--r--testing/marionette/client/marionette_driver/localization.py54
-rw-r--r--testing/marionette/client/marionette_driver/marionette.py2064
-rw-r--r--testing/marionette/client/marionette_driver/timeout.py103
-rw-r--r--testing/marionette/client/marionette_driver/transport.py408
-rw-r--r--testing/marionette/client/marionette_driver/wait.py175
14 files changed, 4330 insertions, 0 deletions
diff --git a/testing/marionette/client/marionette_driver/__init__.py b/testing/marionette/client/marionette_driver/__init__.py
new file mode 100644
index 0000000000..53c18ae0e5
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/__init__.py
@@ -0,0 +1,22 @@
+# 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/.
+
+__version__ = "3.1.0"
+
+from marionette_driver import (
+ addons,
+ by,
+ date_time_value,
+ decorators,
+ errors,
+ expected,
+ geckoinstance,
+ keys,
+ localization,
+ marionette,
+ wait,
+)
+from marionette_driver.by import By
+from marionette_driver.date_time_value import DateTimeValue
+from marionette_driver.wait import Wait
diff --git a/testing/marionette/client/marionette_driver/addons.py b/testing/marionette/client/marionette_driver/addons.py
new file mode 100644
index 0000000000..09f44e3e54
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/addons.py
@@ -0,0 +1,76 @@
+# 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
+
+from . import errors
+
+__all__ = ["Addons", "AddonInstallException"]
+
+
+class AddonInstallException(errors.MarionetteException):
+ pass
+
+
+class Addons(object):
+ """An API for installing and inspecting addons during Gecko
+ runtime. This is a partially implemented wrapper around Gecko's
+ `AddonManager API`_.
+
+ For example::
+
+ from marionette_driver.addons import Addons
+ addons = Addons(marionette)
+ addons.install("/path/to/extension.xpi")
+
+ .. _AddonManager API: https://developer.mozilla.org/en-US/Add-ons/Add-on_Manager
+
+ """
+
+ def __init__(self, marionette):
+ self._mn = marionette
+
+ def install(self, path, temp=False):
+ """Install a Firefox addon.
+
+ If the addon is restartless, it can be used right away. Otherwise
+ a restart using :func:`~marionette_driver.marionette.Marionette.restart`
+ will be needed.
+
+ :param path: A file path to the extension to be installed.
+ :param temp: Install a temporary addon. Temporary addons will
+ automatically be uninstalled on shutdown and do not need
+ to be signed, though they must be restartless.
+
+ :returns: The addon ID string of the newly installed addon.
+
+ :raises: :exc:`AddonInstallException`
+
+ """
+ # On windows we can end up with a path with mixed \ and /
+ # which Firefox doesn't like
+ path = path.replace("/", os.path.sep)
+
+ body = {"path": path, "temporary": temp}
+ try:
+ return self._mn._send_message("Addon:Install", body, key="value")
+ except errors.UnknownException as e:
+ raise AddonInstallException(e)
+
+ def uninstall(self, addon_id):
+ """Uninstall a Firefox addon.
+
+ If the addon is restartless, it will be uninstalled right away.
+ Otherwise a restart using :func:`~marionette_driver.marionette.Marionette.restart`
+ will be needed.
+
+ If the call to uninstall is resulting in a `ScriptTimeoutException`,
+ an invalid ID is likely being passed in. Unfortunately due to
+ AddonManager's implementation, it's hard to retrieve this error from
+ Python.
+
+ :param addon_id: The addon ID string to uninstall.
+
+ """
+ self._mn._send_message("Addon:Uninstall", {"id": addon_id})
diff --git a/testing/marionette/client/marionette_driver/by.py b/testing/marionette/client/marionette_driver/by.py
new file mode 100644
index 0000000000..b54ca729f2
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/by.py
@@ -0,0 +1,25 @@
+# Copyright 2008-2009 WebDriver committers
+# Copyright 2008-2009 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+class By(object):
+ ID = "id"
+ XPATH = "xpath"
+ LINK_TEXT = "link text"
+ PARTIAL_LINK_TEXT = "partial link text"
+ NAME = "name"
+ TAG_NAME = "tag name"
+ CLASS_NAME = "class name"
+ CSS_SELECTOR = "css selector"
diff --git a/testing/marionette/client/marionette_driver/date_time_value.py b/testing/marionette/client/marionette_driver/date_time_value.py
new file mode 100644
index 0000000000..c6f2ed989a
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/date_time_value.py
@@ -0,0 +1,49 @@
+# 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/.
+
+
+class DateTimeValue(object):
+ """
+ Interface for setting the value of HTML5 "date" and "time" input elements.
+
+ Simple usage example:
+
+ ::
+
+ element = marionette.find_element(By.ID, "date-test")
+ dt_value = DateTimeValue(element)
+ dt_value.date = datetime(1998, 6, 2)
+
+ """
+
+ def __init__(self, element):
+ self.element = element
+
+ @property
+ def date(self):
+ """
+ Retrieve the element's string value
+ """
+ return self.element.get_attribute("value")
+
+ # As per the W3C "date" element specification
+ # (http://dev.w3.org/html5/markup/input.date.html), this value is formatted
+ # according to RFC 3339: http://tools.ietf.org/html/rfc3339#section-5.6
+ @date.setter
+ def date(self, date_value):
+ self.element.send_keys(date_value.strftime("%Y-%m-%d"))
+
+ @property
+ def time(self):
+ """
+ Retrieve the element's string value
+ """
+ return self.element.get_attribute("value")
+
+ # As per the W3C "time" element specification
+ # (http://dev.w3.org/html5/markup/input.time.html), this value is formatted
+ # according to RFC 3339: http://tools.ietf.org/html/rfc3339#section-5.6
+ @time.setter
+ def time(self, time_value):
+ self.element.send_keys(time_value.strftime("%H:%M:%S"))
diff --git a/testing/marionette/client/marionette_driver/decorators.py b/testing/marionette/client/marionette_driver/decorators.py
new file mode 100644
index 0000000000..95a5c5bbee
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/decorators.py
@@ -0,0 +1,79 @@
+# 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 socket
+from functools import wraps
+
+
+def _find_marionette_in_args(*args, **kwargs):
+ try:
+ m = [a for a in args + tuple(kwargs.values()) if hasattr(a, "session")][0]
+ except IndexError:
+ print("Can only apply decorator to function using a marionette object")
+ raise
+ return m
+
+
+def do_process_check(func):
+ """Decorator which checks the process status after the function has run."""
+
+ @wraps(func)
+ def _(*args, **kwargs):
+ try:
+ return func(*args, **kwargs)
+ except (socket.error, socket.timeout):
+ m = _find_marionette_in_args(*args, **kwargs)
+
+ # In case of socket failures which will also include crashes of the
+ # application, make sure to handle those correctly. In case of an
+ # active shutdown just let it bubble up.
+ if m.is_shutting_down:
+ raise
+
+ m._handle_socket_failure()
+
+ return _
+
+
+def uses_marionette(func):
+ """Decorator which creates a marionette session and deletes it
+ afterwards if one doesn't already exist.
+ """
+
+ @wraps(func)
+ def _(*args, **kwargs):
+ m = _find_marionette_in_args(*args, **kwargs)
+ delete_session = False
+ if not m.session:
+ delete_session = True
+ m.start_session()
+
+ m.set_context(m.CONTEXT_CHROME)
+ ret = func(*args, **kwargs)
+
+ if delete_session:
+ m.delete_session()
+
+ return ret
+
+ return _
+
+
+def using_context(context):
+ """Decorator which allows a function to execute in certain scope
+ using marionette.using_context functionality and returns to old
+ scope once the function exits.
+ :param context: Either 'chrome' or 'content'.
+ """
+
+ def wrap(func):
+ @wraps(func)
+ def inner(*args, **kwargs):
+ m = _find_marionette_in_args(*args, **kwargs)
+ with m.using_context(context):
+ return func(*args, **kwargs)
+
+ return inner
+
+ return wrap
diff --git a/testing/marionette/client/marionette_driver/errors.py b/testing/marionette/client/marionette_driver/errors.py
new file mode 100644
index 0000000000..09dc9f24ef
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/errors.py
@@ -0,0 +1,206 @@
+# 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 traceback
+
+import six
+
+
+@six.python_2_unicode_compatible
+class MarionetteException(Exception):
+
+ """Raised when a generic non-recoverable exception has occured."""
+
+ status = "webdriver error"
+
+ def __init__(self, message=None, cause=None, stacktrace=None):
+ """Construct new MarionetteException instance.
+
+ :param message: An optional exception message.
+
+ :param cause: An optional tuple of three values giving
+ information about the root exception cause. Expected
+ tuple values are (type, value, traceback).
+
+ :param stacktrace: Optional string containing a stacktrace
+ (typically from a failed JavaScript execution) that will
+ be displayed in the exception's string representation.
+
+ """
+ self.cause = cause
+ self.stacktrace = stacktrace
+ self._message = six.text_type(message)
+
+ def __str__(self):
+ # pylint: disable=W1645
+ msg = self.message
+ tb = None
+
+ if self.cause:
+ if type(self.cause) is tuple:
+ msg += u", caused by {0!r}".format(self.cause[0])
+ tb = self.cause[2]
+ else:
+ msg += u", caused by {}".format(self.cause)
+
+ if self.stacktrace:
+ st = u"".join(["\t{}\n".format(x) for x in self.stacktrace.splitlines()])
+ msg += u"\nstacktrace:\n{}".format(st)
+
+ if tb:
+ msg += u": " + u"".join(traceback.format_tb(tb))
+
+ return six.text_type(msg)
+
+ @property
+ def message(self):
+ return self._message
+
+
+class DetachedShadowRootException(MarionetteException):
+ status = "detached shadow root"
+
+
+class ElementNotSelectableException(MarionetteException):
+ status = "element not selectable"
+
+
+class ElementClickInterceptedException(MarionetteException):
+ status = "element click intercepted"
+
+
+class InsecureCertificateException(MarionetteException):
+ status = "insecure certificate"
+
+
+class InvalidArgumentException(MarionetteException):
+ status = "invalid argument"
+
+
+class InvalidSessionIdException(MarionetteException):
+ status = "invalid session id"
+
+
+class TimeoutException(MarionetteException):
+ status = "timeout"
+
+
+class JavascriptException(MarionetteException):
+ status = "javascript error"
+
+
+class NoSuchElementException(MarionetteException):
+ status = "no such element"
+
+
+class NoSuchShadowRootException(MarionetteException):
+ status = "no such shadow root"
+
+
+class NoSuchWindowException(MarionetteException):
+ status = "no such window"
+
+
+class StaleElementException(MarionetteException):
+ status = "stale element reference"
+
+
+class ScriptTimeoutException(MarionetteException):
+ status = "script timeout"
+
+
+class ElementNotVisibleException(MarionetteException):
+ """Deprecated. Will be removed with the release of Firefox 54."""
+
+ status = "element not visible"
+
+ def __init__(
+ self,
+ message="Element is not currently visible and may not be manipulated",
+ stacktrace=None,
+ cause=None,
+ ):
+ super(ElementNotVisibleException, self).__init__(
+ message, cause=cause, stacktrace=stacktrace
+ )
+
+
+class ElementNotAccessibleException(MarionetteException):
+ status = "element not accessible"
+
+
+class ElementNotInteractableException(MarionetteException):
+ status = "element not interactable"
+
+
+class NoSuchFrameException(MarionetteException):
+ status = "no such frame"
+
+
+class InvalidElementStateException(MarionetteException):
+ status = "invalid element state"
+
+
+class NoAlertPresentException(MarionetteException):
+ status = "no such alert"
+
+
+class InvalidCookieDomainException(MarionetteException):
+ status = "invalid cookie domain"
+
+
+class UnableToSetCookieException(MarionetteException):
+ status = "unable to set cookie"
+
+
+class InvalidElementCoordinates(MarionetteException):
+ status = "invalid element coordinates"
+
+
+class InvalidSelectorException(MarionetteException):
+ status = "invalid selector"
+
+
+class MoveTargetOutOfBoundsException(MarionetteException):
+ status = "move target out of bounds"
+
+
+class SessionNotCreatedException(MarionetteException):
+ status = "session not created"
+
+
+class UnexpectedAlertOpen(MarionetteException):
+ status = "unexpected alert open"
+
+
+class UnknownCommandException(MarionetteException):
+ status = "unknown command"
+
+
+class UnknownException(MarionetteException):
+ status = "unknown error"
+
+
+class UnsupportedOperationException(MarionetteException):
+ status = "unsupported operation"
+
+
+class UnresponsiveInstanceException(Exception):
+ pass
+
+
+es_ = [
+ e
+ for e in locals().values()
+ if type(e) == type and issubclass(e, MarionetteException)
+]
+by_string = {e.status: e for e in es_}
+
+
+def lookup(identifier):
+ """Finds error exception class by associated Selenium JSON wire
+ protocol number code, or W3C WebDriver protocol string.
+
+ """
+ return by_string.get(identifier, MarionetteException)
diff --git a/testing/marionette/client/marionette_driver/expected.py b/testing/marionette/client/marionette_driver/expected.py
new file mode 100644
index 0000000000..6b272663ad
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/expected.py
@@ -0,0 +1,315 @@
+# 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 types
+
+from . import errors
+from .marionette import HTMLElement
+
+"""This file provides a set of expected conditions for common use
+cases when writing Marionette tests.
+
+The conditions rely on explicit waits that retries conditions a number
+of times until they are either successfully met, or they time out.
+
+"""
+
+
+class element_present(object):
+ """Checks that a web element is present in the DOM of the current
+ context. This does not necessarily mean that the element is
+ visible.
+
+ You can select which element to be checked for presence by
+ supplying a locator::
+
+ el = Wait(marionette).until(expected.element_present(By.ID, "foo"))
+
+ Or by using a function/lambda returning an element::
+
+ el = Wait(marionette).until(
+ expected.element_present(lambda m: m.find_element(By.ID, "foo")))
+
+ :param args: locator or function returning web element
+ :returns: the web element once it is located, or False
+
+ """
+
+ def __init__(self, *args):
+ if len(args) == 1 and isinstance(args[0], types.FunctionType):
+ self.locator = args[0]
+ else:
+ self.locator = lambda m: m.find_element(*args)
+
+ def __call__(self, marionette):
+ return _find(marionette, self.locator)
+
+
+class element_not_present(element_present):
+ """Checks that a web element is not present in the DOM of the current
+ context.
+
+ You can select which element to be checked for lack of presence by
+ supplying a locator::
+
+ r = Wait(marionette).until(expected.element_not_present(By.ID, "foo"))
+
+ Or by using a function/lambda returning an element::
+
+ r = Wait(marionette).until(
+ expected.element_present(lambda m: m.find_element(By.ID, "foo")))
+
+ :param args: locator or function returning web element
+ :returns: True if element is not present, or False if it is present
+
+ """
+
+ def __init__(self, *args):
+ super(element_not_present, self).__init__(*args)
+
+ def __call__(self, marionette):
+ return not super(element_not_present, self).__call__(marionette)
+
+
+class element_stale(object):
+ """Check that the given element is no longer attached to DOM of the
+ current context.
+
+ This can be useful for waiting until an element is no longer
+ present.
+
+ Sample usage::
+
+ el = marionette.find_element(By.ID, "foo")
+ # ...
+ Wait(marionette).until(expected.element_stale(el))
+
+ :param element: the element to wait for
+ :returns: False if the element is still attached to the DOM, True
+ otherwise
+
+ """
+
+ def __init__(self, element):
+ self.el = element
+
+ def __call__(self, marionette):
+ try:
+ # Calling any method forces a staleness check
+ self.el.is_enabled()
+ return False
+ except (errors.StaleElementException, errors.NoSuchElementException):
+ # StaleElementException is raised when the element is gone, and
+ # NoSuchElementException is raised after a process swap.
+ return True
+
+
+class elements_present(object):
+ """Checks that web elements are present in the DOM of the current
+ context. This does not necessarily mean that the elements are
+ visible.
+
+ You can select which elements to be checked for presence by
+ supplying a locator::
+
+ els = Wait(marionette).until(expected.elements_present(By.TAG_NAME, "a"))
+
+ Or by using a function/lambda returning a list of elements::
+
+ els = Wait(marionette).until(
+ expected.elements_present(lambda m: m.find_elements(By.TAG_NAME, "a")))
+
+ :param args: locator or function returning a list of web elements
+ :returns: list of web elements once they are located, or False
+
+ """
+
+ def __init__(self, *args):
+ if len(args) == 1 and isinstance(args[0], types.FunctionType):
+ self.locator = args[0]
+ else:
+ self.locator = lambda m: m.find_elements(*args)
+
+ def __call__(self, marionette):
+ return _find(marionette, self.locator)
+
+
+class elements_not_present(elements_present):
+ """Checks that web elements are not present in the DOM of the
+ current context.
+
+ You can select which elements to be checked for not being present
+ by supplying a locator::
+
+ r = Wait(marionette).until(expected.elements_not_present(By.TAG_NAME, "a"))
+
+ Or by using a function/lambda returning a list of elements::
+
+ r = Wait(marionette).until(
+ expected.elements_not_present(lambda m: m.find_elements(By.TAG_NAME, "a")))
+
+ :param args: locator or function returning a list of web elements
+ :returns: True if elements are missing, False if one or more are
+ present
+
+ """
+
+ def __init__(self, *args):
+ super(elements_not_present, self).__init__(*args)
+
+ def __call__(self, marionette):
+ return not super(elements_not_present, self).__call__(marionette)
+
+
+class element_displayed(object):
+ """An expectation for checking that an element is visible.
+
+ Visibility means that the element is not only displayed, but also
+ has a height and width that is greater than 0 pixels.
+
+ Stale elements, meaning elements that have been detached from the
+ DOM of the current context are treated as not being displayed,
+ meaning this expectation is not analogous to the behaviour of
+ calling :func:`~marionette_driver.marionette.HTMLElement.is_displayed`
+ on an :class:`~marionette_driver.marionette.HTMLElement`.
+
+ You can select which element to be checked for visibility by
+ supplying a locator::
+
+ displayed = Wait(marionette).until(expected.element_displayed(By.ID, "foo"))
+
+ Or by supplying an element::
+
+ el = marionette.find_element(By.ID, "foo")
+ displayed = Wait(marionette).until(expected.element_displayed(el))
+
+ :param args: locator or web element
+ :returns: True if element is displayed, False if hidden
+
+ """
+
+ def __init__(self, *args):
+ self.el = None
+ if len(args) == 1 and isinstance(args[0], HTMLElement):
+ self.el = args[0]
+ else:
+ self.locator = lambda m: m.find_element(*args)
+
+ def __call__(self, marionette):
+ if self.el is None:
+ self.el = _find(marionette, self.locator)
+ if not self.el:
+ return False
+ try:
+ return self.el.is_displayed()
+ except errors.StaleElementException:
+ return False
+
+
+class element_not_displayed(element_displayed):
+ """An expectation for checking that an element is not visible.
+
+ Visibility means that the element is not only displayed, but also
+ has a height and width that is greater than 0 pixels.
+
+ Stale elements, meaning elements that have been detached fom the
+ DOM of the current context are treated as not being displayed,
+ meaning this expectation is not analogous to the behaviour of
+ calling :func:`~marionette_driver.marionette.HTMLElement.is_displayed`
+ on an :class:`~marionette_driver.marionette.HTMLElement`.
+
+ You can select which element to be checked for visibility by
+ supplying a locator::
+
+ hidden = Wait(marionette).until(expected.element_not_displayed(By.ID, "foo"))
+
+ Or by supplying an element::
+
+ el = marionette.find_element(By.ID, "foo")
+ hidden = Wait(marionette).until(expected.element_not_displayed(el))
+
+ :param args: locator or web element
+ :returns: True if element is hidden, False if displayed
+
+ """
+
+ def __init__(self, *args):
+ super(element_not_displayed, self).__init__(*args)
+
+ def __call__(self, marionette):
+ return not super(element_not_displayed, self).__call__(marionette)
+
+
+class element_selected(object):
+ """An expectation for checking that the given element is selected.
+
+ :param element: the element to be selected
+ :returns: True if element is selected, False otherwise
+
+ """
+
+ def __init__(self, element):
+ self.el = element
+
+ def __call__(self, marionette):
+ return self.el.is_selected()
+
+
+class element_not_selected(element_selected):
+ """An expectation for checking that the given element is not
+ selected.
+
+ :param element: the element to not be selected
+ :returns: True if element is not selected, False if selected
+
+ """
+
+ def __init__(self, element):
+ super(element_not_selected, self).__init__(element)
+
+ def __call__(self, marionette):
+ return not super(element_not_selected, self).__call__(marionette)
+
+
+class element_enabled(object):
+ """An expectation for checking that the given element is enabled.
+
+ :param element: the element to check if enabled
+ :returns: True if element is enabled, False otherwise
+
+ """
+
+ def __init__(self, element):
+ self.el = element
+
+ def __call__(self, marionette):
+ return self.el.is_enabled()
+
+
+class element_not_enabled(element_enabled):
+ """An expectation for checking that the given element is disabled.
+
+ :param element: the element to check if disabled
+ :returns: True if element is disabled, False if enabled
+
+ """
+
+ def __init__(self, element):
+ super(element_not_enabled, self).__init__(element)
+
+ def __call__(self, marionette):
+ return not super(element_not_enabled, self).__call__(marionette)
+
+
+def _find(marionette, func):
+ el = None
+
+ try:
+ el = func(marionette)
+ except errors.NoSuchElementException:
+ pass
+
+ if el is None:
+ return False
+ return el
diff --git a/testing/marionette/client/marionette_driver/geckoinstance.py b/testing/marionette/client/marionette_driver/geckoinstance.py
new file mode 100644
index 0000000000..24c1392495
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/geckoinstance.py
@@ -0,0 +1,666 @@
+# 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/
+
+# ALL CHANGES TO THIS FILE MUST HAVE REVIEW FROM A MARIONETTE PEER!
+#
+# Please refer to INSTRUCTIONS TO ADD A NEW PREFERENCE in
+# remote/shared/RecommendedPreferences.sys.mjs
+#
+# The Marionette Python client is used out-of-tree with various builds of
+# Firefox. Removing a preference from this file will cause regressions,
+# so please be careful and get review from a Testing :: Marionette peer
+# before you make any changes to this file.
+
+import codecs
+import json
+import os
+import sys
+import tempfile
+import time
+import traceback
+from copy import deepcopy
+
+import mozversion
+import six
+from mozprofile import Profile
+from mozrunner import FennecEmulatorRunner, Runner
+from six import reraise
+
+from . import errors
+
+
+class GeckoInstance(object):
+ required_prefs = {
+ # Make sure Shield doesn't hit the network.
+ "app.normandy.api_url": "",
+ # Increase the APZ content response timeout in tests to 1 minute.
+ # This is to accommodate the fact that test environments tends to be slower
+ # than production environments (with the b2g emulator being the slowest of them
+ # all), resulting in the production timeout value sometimes being exceeded
+ # and causing false-positive test failures. See bug 1176798, bug 1177018,
+ # bug 1210465.
+ "apz.content_response_timeout": 60000,
+ # Disable geolocation ping (#1)
+ "browser.region.network.url": "",
+ # Don't pull Top Sites content from the network
+ "browser.topsites.contile.enabled": False,
+ # Disable UI tour
+ "browser.uitour.pinnedTabUrl": "http://%(server)s/uitour-dummy/pinnedTab",
+ "browser.uitour.url": "http://%(server)s/uitour-dummy/tour",
+ # Disable captive portal
+ "captivedetect.canonicalURL": "",
+ # Defensively disable data reporting systems
+ "datareporting.healthreport.documentServerURI": "http://%(server)s/dummy/healthreport/",
+ "datareporting.healthreport.logging.consoleEnabled": False,
+ "datareporting.healthreport.service.enabled": False,
+ "datareporting.healthreport.service.firstRun": False,
+ "datareporting.healthreport.uploadEnabled": False,
+ # Do not show datareporting policy notifications which can interfere with tests
+ "datareporting.policy.dataSubmissionEnabled": False,
+ "datareporting.policy.dataSubmissionPolicyBypassNotification": True,
+ # Automatically unload beforeunload alerts
+ "dom.disable_beforeunload": True,
+ # Enabling the support for File object creation in the content process.
+ "dom.file.createInChild": 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,
+ # Disable location change rate limitation
+ "dom.navigation.locationChangeRateLimit.count": 0,
+ # DOM Push
+ "dom.push.connection.enabled": False,
+ # Screen Orientation API
+ "dom.screenorientation.allow-lock": True,
+ # Disable dialog abuse if alerts are triggered too quickly
+ "dom.successive_dialog_time_limit": 0,
+ # Only load extensions from the application and user profile
+ # AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION
+ "extensions.autoDisableScopes": 0,
+ "extensions.enabledScopes": 5,
+ # Disable metadata caching for installed add-ons by default
+ "extensions.getAddons.cache.enabled": False,
+ # Disable intalling any distribution add-ons
+ "extensions.installDistroAddons": False,
+ # Turn off extension updates so they don't bother tests
+ "extensions.update.enabled": False,
+ "extensions.update.notifyUser": False,
+ # Redirect various extension update URLs
+ "extensions.blocklist.detailsURL": (
+ "http://%(server)s/extensions-dummy/blocklistDetailsURL"
+ ),
+ "extensions.blocklist.itemURL": "http://%(server)s/extensions-dummy/blocklistItemURL",
+ "extensions.hotfix.url": "http://%(server)s/extensions-dummy/hotfixURL",
+ "extensions.systemAddon.update.url": "http://%(server)s/dummy-system-addons.xml",
+ "extensions.update.background.url": (
+ "http://%(server)s/extensions-dummy/updateBackgroundURL"
+ ),
+ "extensions.update.url": "http://%(server)s/extensions-dummy/updateURL",
+ # Make sure opening about:addons won"t hit the network
+ "extensions.getAddons.discovery.api_url": "data:, ",
+ "extensions.getAddons.get.url": "http://%(server)s/extensions-dummy/repositoryGetURL",
+ "extensions.getAddons.search.browseURL": (
+ "http://%(server)s/extensions-dummy/repositoryBrowseURL"
+ ),
+ # Allow the application to have focus even it runs in the background
+ "focusmanager.testmode": True,
+ # Disable useragent updates
+ "general.useragent.updates.enabled": False,
+ # Disable geolocation ping (#2)
+ "geo.provider.network.url": "",
+ # Always use network provider for geolocation tests
+ # so we bypass the OSX dialog raised by the corelocation provider
+ "geo.provider.testing": True,
+ # Do not scan Wifi
+ "geo.wifi.scan": False,
+ # Ensure webrender is on, no need for environment variables
+ "gfx.webrender.all": True,
+ # Disable idle-daily notifications to avoid expensive operations
+ # that may cause unexpected test timeouts.
+ "idle.lastDailyNotification": -1,
+ # Disable Firefox accounts ping
+ "identity.fxaccounts.auth.uri": "https://{server}/dummy/fxa",
+ # Disable download and usage of OpenH264, and Widevine plugins
+ "media.gmp-manager.updateEnabled": False,
+ # Disable the GFX sanity window
+ "media.sanity-test.disabled": True,
+ "media.volume_scale": "0.01",
+ # Disable connectivity service pings
+ "network.connectivity-service.enabled": False,
+ # Do not prompt for temporary redirects
+ "network.http.prompt-temp-redirect": False,
+ # Do not automatically switch between offline and online
+ "network.manage-offline-status": False,
+ # Make sure SNTP requests don't hit the network
+ "network.sntp.pools": "%(server)s",
+ # Privacy and Tracking Protection
+ "privacy.trackingprotection.enabled": False,
+ # Disable recommended automation prefs in CI
+ "remote.prefs.recommended": False,
+ # Don't do network connections for mitm priming
+ "security.certerrors.mitm.priming.enabled": False,
+ # Tests don't wait for the notification button security delay
+ "security.notification_enable_delay": 0,
+ # Ensure blocklist updates don't hit the network
+ "services.settings.server": "data:,#remote-settings-dummy/v1",
+ # Disable password capture, so that tests that include forms aren"t
+ # influenced by the presence of the persistent doorhanger notification
+ "signon.rememberSignons": False,
+ # Prevent starting into safe mode after application crashes
+ "toolkit.startup.max_resumed_crashes": -1,
+ # Disable most telemetry pings
+ "toolkit.telemetry.server": "https://%(server)s/telemetry-dummy/",
+ # Disable window occlusion on Windows, see Bug 1802473.
+ "widget.windows.window_occlusion_tracking.enabled": False,
+ }
+
+ def __init__(
+ self,
+ host=None,
+ port=None,
+ bin=None,
+ profile=None,
+ addons=None,
+ app_args=None,
+ symbols_path=None,
+ gecko_log=None,
+ prefs=None,
+ workspace=None,
+ verbose=0,
+ headless=False,
+ ):
+ self.runner_class = Runner
+ self.app_args = app_args or []
+ self.runner = None
+ self.symbols_path = symbols_path
+ self.binary = bin
+
+ self.marionette_host = host
+ self.marionette_port = port
+ self.addons = addons
+ self.prefs = prefs
+ self.required_prefs = deepcopy(self.required_prefs)
+ if prefs:
+ self.required_prefs.update(prefs)
+
+ self._gecko_log_option = gecko_log
+ self._gecko_log = None
+ self.verbose = verbose
+ self.headless = headless
+
+ # keep track of errors to decide whether instance is unresponsive
+ self.unresponsive_count = 0
+
+ # Alternative to default temporary directory
+ self.workspace = workspace
+
+ # Don't use the 'profile' property here, because sub-classes could add
+ # further preferences and data, which would not be included in the new
+ # profile
+ self._profile = profile
+
+ @property
+ def gecko_log(self):
+ if self._gecko_log:
+ return self._gecko_log
+
+ path = self._gecko_log_option
+ if path != "-":
+ if path is None:
+ path = "gecko.log"
+ elif os.path.isdir(path):
+ fname = "gecko-{}.log".format(time.time())
+ path = os.path.join(path, fname)
+
+ path = os.path.realpath(path)
+ if os.access(path, os.F_OK):
+ os.remove(path)
+
+ self._gecko_log = path
+ return self._gecko_log
+
+ @property
+ def profile(self):
+ return self._profile
+
+ @profile.setter
+ def profile(self, value):
+ self._update_profile(value)
+
+ def _update_profile(self, profile=None, profile_name=None):
+ """Check if the profile has to be created, or replaced.
+
+ :param profile: A Profile instance to be used.
+ :param name: Profile name to be used in the path.
+ """
+ if self.runner and self.runner.is_running():
+ raise errors.MarionetteException(
+ "The current profile can only be updated "
+ "when the instance is not running"
+ )
+
+ if isinstance(profile, Profile):
+ # Only replace the profile if it is not the current one
+ if hasattr(self, "_profile") and profile is self._profile:
+ return
+
+ else:
+ profile_args = self.profile_args
+ profile_path = profile
+
+ # If a path to a profile is given then clone it
+ if isinstance(profile_path, six.string_types):
+ profile_args["path_from"] = profile_path
+ profile_args["path_to"] = tempfile.mkdtemp(
+ suffix=u".{}".format(
+ profile_name or os.path.basename(profile_path)
+ ),
+ dir=self.workspace,
+ )
+ # The target must not exist yet
+ os.rmdir(profile_args["path_to"])
+
+ profile = Profile.clone(**profile_args)
+
+ # Otherwise create a new profile
+ else:
+ profile_args["profile"] = tempfile.mkdtemp(
+ suffix=u".{}".format(profile_name or "mozrunner"),
+ dir=self.workspace,
+ )
+ profile = Profile(**profile_args)
+ profile.create_new = True
+
+ if isinstance(self.profile, Profile):
+ self.profile.cleanup()
+
+ self._profile = profile
+
+ def switch_profile(self, profile_name=None, clone_from=None):
+ """Switch the profile by using the given name, and optionally clone it.
+
+ Compared to :attr:`profile` this method allows to switch the profile
+ by giving control over the profile name as used for the new profile. It
+ also always creates a new blank profile, or as clone of an existent one.
+
+ :param profile_name: Optional, name of the profile, which will be used
+ as part of the profile path (folder name containing the profile).
+ :clone_from: Optional, if specified the new profile will be cloned
+ based on the given profile. This argument can be an instance of
+ ``mozprofile.Profile``, or the path of the profile.
+ """
+ if isinstance(clone_from, Profile):
+ clone_from = clone_from.profile
+
+ self._update_profile(clone_from, profile_name=profile_name)
+
+ @property
+ def profile_args(self):
+ args = {"preferences": deepcopy(self.required_prefs)}
+ args["preferences"]["marionette.port"] = self.marionette_port
+ args["preferences"]["marionette.defaultPrefs.port"] = self.marionette_port
+
+ if self.prefs:
+ args["preferences"].update(self.prefs)
+
+ if self.verbose:
+ level = "Trace" if self.verbose >= 2 else "Debug"
+ args["preferences"]["marionette.log.level"] = level
+ args["preferences"]["marionette.logging"] = level
+
+ if "-jsdebugger" in self.app_args:
+ args["preferences"].update(
+ {
+ "devtools.browsertoolbox.panel": "jsdebugger",
+ "devtools.chrome.enabled": True,
+ "devtools.debugger.prompt-connection": False,
+ "devtools.debugger.remote-enabled": True,
+ "devtools.testing": True,
+ }
+ )
+
+ if self.addons:
+ args["addons"] = self.addons
+
+ return args
+
+ @classmethod
+ def create(cls, app=None, *args, **kwargs):
+ try:
+ if not app and kwargs["bin"] is not None:
+ app_id = mozversion.get_version(binary=kwargs["bin"])["application_id"]
+ app = app_ids[app_id]
+
+ instance_class = apps[app]
+ except (IOError, KeyError):
+ exc, val, tb = sys.exc_info()
+ msg = 'Application "{0}" unknown (should be one of {1})'.format(
+ app, list(apps.keys())
+ )
+ reraise(NotImplementedError, NotImplementedError(msg), tb)
+
+ return instance_class(*args, **kwargs)
+
+ def start(self):
+ self._update_profile(self.profile)
+ self.runner = self.runner_class(**self._get_runner_args())
+ self.runner.start()
+
+ def _get_runner_args(self):
+ process_args = {
+ "processOutputLine": [NullOutput()],
+ "universal_newlines": True,
+ }
+
+ if self.gecko_log == "-":
+ if hasattr(sys.stdout, "buffer"):
+ process_args["stream"] = codecs.getwriter("utf-8")(sys.stdout.buffer)
+ else:
+ process_args["stream"] = codecs.getwriter("utf-8")(sys.stdout)
+ else:
+ process_args["logfile"] = self.gecko_log
+
+ env = os.environ.copy()
+
+ # Store all required preferences for tests which need to create clean profiles.
+ required_prefs_keys = list(self.required_prefs.keys())
+ env["MOZ_MARIONETTE_REQUIRED_PREFS"] = json.dumps(required_prefs_keys)
+
+ if self.headless:
+ env["MOZ_HEADLESS"] = "1"
+ env["DISPLAY"] = "77" # Set a fake display.
+
+ # environment variables needed for crashreporting
+ # https://developer.mozilla.org/docs/Environment_variables_affecting_crash_reporting
+ env.update(
+ {
+ "MOZ_CRASHREPORTER": "1",
+ "MOZ_CRASHREPORTER_NO_REPORT": "1",
+ "MOZ_CRASHREPORTER_SHUTDOWN": "1",
+ }
+ )
+
+ return {
+ "binary": self.binary,
+ "profile": self.profile,
+ "cmdargs": ["-no-remote", "-marionette"] + self.app_args,
+ "env": env,
+ "symbols_path": self.symbols_path,
+ "process_args": process_args,
+ }
+
+ def close(self, clean=False):
+ """
+ Close the managed Gecko process.
+
+ Depending on self.runner_class, setting `clean` to True may also kill
+ the emulator process in which this instance is running.
+
+ :param clean: If True, also perform runner cleanup.
+ """
+ if self.runner:
+ self.runner.stop()
+ if clean:
+ self.runner.cleanup()
+
+ if clean:
+ if isinstance(self.profile, Profile):
+ self.profile.cleanup()
+ self.profile = None
+
+ def restart(self, prefs=None, clean=True):
+ """
+ Close then start the managed Gecko process.
+
+ :param prefs: Dictionary of preference names and values.
+ :param clean: If True, reset the profile before starting.
+ """
+ if prefs:
+ self.prefs = prefs
+ else:
+ self.prefs = None
+
+ self.close(clean=clean)
+ self.start()
+
+
+class FennecInstance(GeckoInstance):
+ fennec_prefs = {
+ # Enable output for dump() and chrome console API
+ "browser.dom.window.dump.enabled": True,
+ "devtools.console.stdout.chrome": True,
+ # Disable safe browsing / tracking protection updates
+ "browser.safebrowsing.update.enabled": False,
+ # Do not restore the last open set of tabs if the browser has crashed
+ "browser.sessionstore.resume_from_crash": False,
+ # Disable e10s by default
+ "browser.tabs.remote.autostart": 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,
+ }
+
+ def __init__(
+ self,
+ emulator_binary=None,
+ avd_home=None,
+ avd=None,
+ adb_path=None,
+ serial=None,
+ connect_to_running_emulator=False,
+ package_name=None,
+ env=None,
+ *args,
+ **kwargs
+ ):
+ required_prefs = deepcopy(FennecInstance.fennec_prefs)
+ required_prefs.update(kwargs.get("prefs", {}))
+
+ super(FennecInstance, self).__init__(*args, **kwargs)
+ self.required_prefs.update(required_prefs)
+
+ self.runner_class = FennecEmulatorRunner
+ # runner args
+ self._package_name = package_name
+ self.emulator_binary = emulator_binary
+ self.avd_home = avd_home
+ self.adb_path = adb_path
+ self.avd = avd
+ self.env = env
+ self.serial = serial
+ self.connect_to_running_emulator = connect_to_running_emulator
+
+ @property
+ def package_name(self):
+ """
+ Name of app to run on emulator.
+
+ Note that FennecInstance does not use self.binary
+ """
+ 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):
+ self._update_profile(self.profile)
+ self.runner = self.runner_class(**self._get_runner_args())
+ try:
+ if self.connect_to_running_emulator:
+ self.runner.device.connect()
+ self.runner.start()
+ except Exception:
+ exc_cls, exc, tb = sys.exc_info()
+ reraise(
+ exc_cls,
+ exc_cls("Error possibly due to runner or device args: {}".format(exc)),
+ tb,
+ )
+
+ # forward marionette port
+ self.runner.device.device.forward(
+ local="tcp:{}".format(self.marionette_port),
+ remote="tcp:{}".format(self.marionette_port),
+ )
+
+ def _get_runner_args(self):
+ process_args = {
+ "processOutputLine": [NullOutput()],
+ "universal_newlines": True,
+ }
+
+ env = {} if self.env is None else self.env.copy()
+
+ runner_args = {
+ "app": self.package_name,
+ "avd_home": self.avd_home,
+ "adb_path": self.adb_path,
+ "binary": self.emulator_binary,
+ "env": env,
+ "profile": self.profile,
+ "cmdargs": ["-marionette"] + self.app_args,
+ "symbols_path": self.symbols_path,
+ "process_args": process_args,
+ "logdir": self.workspace or os.getcwd(),
+ "serial": self.serial,
+ }
+ if self.avd:
+ runner_args["avd"] = self.avd
+
+ return runner_args
+
+ def close(self, clean=False):
+ """
+ Close the managed Gecko process.
+
+ If `clean` is True and the Fennec instance is running in an
+ emulator managed by mozrunner, this will stop the emulator.
+
+ :param clean: If True, also perform runner cleanup.
+ """
+ super(FennecInstance, self).close(clean)
+ if clean and self.runner and self.runner.device.connected:
+ try:
+ self.runner.device.device.remove_forwards(
+ "tcp:{}".format(self.marionette_port)
+ )
+ self.unresponsive_count = 0
+ except Exception:
+ self.unresponsive_count += 1
+ traceback.print_exception(*sys.exc_info())
+
+
+class DesktopInstance(GeckoInstance):
+ desktop_prefs = {
+ # Disable Firefox old build background check
+ "app.update.checkInstallTime": False,
+ # Disable automatically upgrading Firefox
+ #
+ # Note: Possible update tests could reset or flip the value to allow
+ # updates to be downloaded and applied.
+ "app.update.disabledForTesting": True,
+ # !!! For backward compatibility up to Firefox 64. Only remove
+ # when this Firefox version is no longer supported by the client !!!
+ "app.update.auto": False,
+ # Don't show the content blocking introduction panel
+ # We use a larger number than the default 22 to have some buffer
+ # This can be removed once Firefox 69 and 68 ESR and are no longer supported.
+ "browser.contentblocking.introCount": 99,
+ # Enable output for dump() and chrome console API
+ "browser.dom.window.dump.enabled": True,
+ "devtools.console.stdout.chrome": True,
+ # Indicate that the download panel has been shown once so that whichever
+ # download test runs first doesn"t show the popup inconsistently
+ "browser.download.panel.shown": True,
+ # Do not show the EULA notification which can interfer with tests
+ "browser.EULA.override": True,
+ # Disable Activity Stream telemetry pings
+ "browser.newtabpage.activity-stream.telemetry": False,
+ # Always display a blank page
+ "browser.newtabpage.enabled": False,
+ # Background thumbnails in particular cause grief, and disabling thumbnails
+ # in general can"t hurt - we re-enable them when tests need them
+ "browser.pagethumbnails.capturing_disabled": True,
+ # Disable safe browsing / tracking protection updates
+ "browser.safebrowsing.update.enabled": False,
+ # Disable updates to search engines
+ "browser.search.update": False,
+ # Do not restore the last open set of tabs if the browser has crashed
+ "browser.sessionstore.resume_from_crash": False,
+ # Don't check for the default web browser during startup
+ "browser.shell.checkDefaultBrowser": False,
+ # Disable session restore infobar
+ "browser.startup.couldRestoreSession.count": -1,
+ # Needed for branded builds to prevent opening a second tab on startup
+ "browser.startup.homepage_override.mstone": "ignore",
+ # Start with a blank page by default
+ "browser.startup.page": 0,
+ # Don't unload tabs when available memory is running low
+ "browser.tabs.unloadOnLowMemory": False,
+ # Do not warn when closing all open tabs
+ "browser.tabs.warnOnClose": False,
+ # Do not warn when closing all other open tabs
+ "browser.tabs.warnOnCloseOtherTabs": False,
+ # Do not warn when multiple tabs will be opened
+ "browser.tabs.warnOnOpen": False,
+ # Don't show the Bookmarks Toolbar on any tab (the above pref that
+ # disables the New Tab Page ends up showing the toolbar on about:blank).
+ "browser.toolbars.bookmarks.visibility": "never",
+ # Disable the UI tour
+ "browser.uitour.enabled": False,
+ # Turn off Merino suggestions in the location bar so as not to trigger network
+ # connections.
+ "browser.urlbar.merino.endpointURL": "",
+ # Turn off search suggestions in the location bar so as not to trigger network
+ # connections.
+ "browser.urlbar.suggest.searches": False,
+ # Don't warn when exiting the browser
+ "browser.warnOnQuit": False,
+ # Disable first-run welcome page
+ "startup.homepage_welcome_url": "about:blank",
+ "startup.homepage_welcome_url.additional": "",
+ }
+
+ def __init__(self, *args, **kwargs):
+ required_prefs = deepcopy(DesktopInstance.desktop_prefs)
+ required_prefs.update(kwargs.get("prefs", {}))
+
+ super(DesktopInstance, self).__init__(*args, **kwargs)
+ self.required_prefs.update(required_prefs)
+
+
+class ThunderbirdInstance(GeckoInstance):
+ def __init__(self, *args, **kwargs):
+ super(ThunderbirdInstance, self).__init__(*args, **kwargs)
+ try:
+ # Copied alongside in the test archive
+ from .thunderbirdinstance import thunderbird_prefs
+ except ImportError:
+ try:
+ # Coming from source tree through virtualenv
+ from thunderbirdinstance import thunderbird_prefs
+ except ImportError:
+ thunderbird_prefs = {}
+ self.required_prefs.update(thunderbird_prefs)
+
+
+class NullOutput(object):
+ def __call__(self, line):
+ pass
+
+
+apps = {
+ "fennec": FennecInstance,
+ "fxdesktop": DesktopInstance,
+ "thunderbird": ThunderbirdInstance,
+}
+
+app_ids = {
+ "{aa3c5121-dab2-40e2-81ca-7ea25febc110}": "fennec",
+ "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}": "fxdesktop",
+ "{3550f703-e582-4d05-9a08-453d09bdfdc6}": "thunderbird",
+}
diff --git a/testing/marionette/client/marionette_driver/keys.py b/testing/marionette/client/marionette_driver/keys.py
new file mode 100644
index 0000000000..c53829b149
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/keys.py
@@ -0,0 +1,88 @@
+# 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/.
+
+# copyright 2008-2009 WebDriver committers
+# Copyright 2008-2009 Google Inc.
+#
+# Licensed under the Apache License Version 2.0 = uthe "License")
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http //www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing software
+# distributed under the License is distributed on an "AS IS" BASIS
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+class Keys(object):
+
+ NULL = u"\ue000"
+ CANCEL = u"\ue001" # ^break
+ HELP = u"\ue002"
+ BACK_SPACE = u"\ue003"
+ TAB = u"\ue004"
+ CLEAR = u"\ue005"
+ RETURN = u"\ue006"
+ ENTER = u"\ue007"
+ SHIFT = u"\ue008"
+ LEFT_SHIFT = u"\ue008" # alias
+ CONTROL = u"\ue009"
+ LEFT_CONTROL = u"\ue009" # alias
+ ALT = u"\ue00a"
+ LEFT_ALT = u"\ue00a" # alias
+ PAUSE = u"\ue00b"
+ ESCAPE = u"\ue00c"
+ SPACE = u"\ue00d"
+ PAGE_UP = u"\ue00e"
+ PAGE_DOWN = u"\ue00f"
+ END = u"\ue010"
+ HOME = u"\ue011"
+ LEFT = u"\ue012"
+ ARROW_LEFT = u"\ue012" # alias
+ UP = u"\ue013"
+ ARROW_UP = u"\ue013" # alias
+ RIGHT = u"\ue014"
+ ARROW_RIGHT = u"\ue014" # alias
+ DOWN = u"\ue015"
+ ARROW_DOWN = u"\ue015" # alias
+ INSERT = u"\ue016"
+ DELETE = u"\ue017"
+ SEMICOLON = u"\ue018"
+ EQUALS = u"\ue019"
+
+ NUMPAD0 = u"\ue01a" # numbe pad keys
+ NUMPAD1 = u"\ue01b"
+ NUMPAD2 = u"\ue01c"
+ NUMPAD3 = u"\ue01d"
+ NUMPAD4 = u"\ue01e"
+ NUMPAD5 = u"\ue01f"
+ NUMPAD6 = u"\ue020"
+ NUMPAD7 = u"\ue021"
+ NUMPAD8 = u"\ue022"
+ NUMPAD9 = u"\ue023"
+ MULTIPLY = u"\ue024"
+ ADD = u"\ue025"
+ SEPARATOR = u"\ue026"
+ SUBTRACT = u"\ue027"
+ DECIMAL = u"\ue028"
+ DIVIDE = u"\ue029"
+
+ F1 = u"\ue031" # function keys
+ F2 = u"\ue032"
+ F3 = u"\ue033"
+ F4 = u"\ue034"
+ F5 = u"\ue035"
+ F6 = u"\ue036"
+ F7 = u"\ue037"
+ F8 = u"\ue038"
+ F9 = u"\ue039"
+ F10 = u"\ue03a"
+ F11 = u"\ue03b"
+ F12 = u"\ue03c"
+
+ META = u"\ue03d"
+ COMMAND = u"\ue03d"
diff --git a/testing/marionette/client/marionette_driver/localization.py b/testing/marionette/client/marionette_driver/localization.py
new file mode 100644
index 0000000000..fccb32f416
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/localization.py
@@ -0,0 +1,54 @@
+# 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/.
+
+
+class L10n(object):
+ """An API which allows Marionette to handle localized content.
+
+ The `localization`_ of UI elements in Gecko based applications is done via
+ entities and properties. For static values entities are used, which are located
+ in .dtd files. Whereby for dynamically updated content the values come from
+ .property files. Both types of elements can be identifed via a unique id,
+ and the translated content retrieved.
+
+ For example::
+
+ from marionette_driver.localization import L10n
+ l10n = L10n(marionette)
+
+ l10n.localize_entity(["chrome://branding/locale/brand.dtd"], "brandShortName")
+ l10n.localize_property(["chrome://global/locale/findbar.properties"], "FastFind"))
+
+ .. _localization: https://mzl.la/2eUMjyF
+ """
+
+ def __init__(self, marionette):
+ self._marionette = marionette
+
+ def localize_entity(self, dtd_urls, entity_id):
+ """Retrieve the localized string for the specified entity id.
+
+ :param dtd_urls: List of .dtd URLs which will be used to search for the entity.
+ :param entity_id: ID of the entity to retrieve the localized string for.
+
+ :returns: The localized string for the requested entity.
+ :raises: :exc:`NoSuchElementException`
+ """
+ body = {"urls": dtd_urls, "id": entity_id}
+ return self._marionette._send_message("L10n:LocalizeEntity", body, key="value")
+
+ def localize_property(self, properties_urls, property_id):
+ """Retrieve the localized string for the specified property id.
+
+ :param properties_urls: List of .properties URLs which will be used to
+ search for the property.
+ :param property_id: ID of the property to retrieve the localized string for.
+
+ :returns: The localized string for the requested property.
+ :raises: :exc:`NoSuchElementException`
+ """
+ body = {"urls": properties_urls, "id": property_id}
+ return self._marionette._send_message(
+ "L10n:LocalizeProperty", body, key="value"
+ )
diff --git a/testing/marionette/client/marionette_driver/marionette.py b/testing/marionette/client/marionette_driver/marionette.py
new file mode 100644
index 0000000000..f7bed9b68d
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/marionette.py
@@ -0,0 +1,2064 @@
+# 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 base64
+import datetime
+import json
+import os
+import socket
+import sys
+import time
+import traceback
+from contextlib import contextmanager
+
+import six
+from six import reraise
+
+from . import errors, transport
+from .decorators import do_process_check
+from .geckoinstance import GeckoInstance
+from .keys import Keys
+from .timeout import Timeouts
+
+FRAME_KEY = "frame-075b-4da1-b6ba-e579c2d3230a"
+WEB_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf"
+WEB_SHADOW_ROOT_KEY = "shadow-6066-11e4-a52e-4f735466cecf"
+WINDOW_KEY = "window-fcc6-11e5-b4f8-330a88ab9d7f"
+
+
+class MouseButton(object):
+ """Enum-like class for mouse button constants."""
+
+ LEFT = 0
+ MIDDLE = 1
+ RIGHT = 2
+
+
+class ActionSequence(object):
+ r"""API for creating and performing action sequences.
+
+ Each action method adds one or more actions to a queue. When perform()
+ is called, the queued actions fire in order.
+
+ May be chained together as in::
+
+ ActionSequence(self.marionette, "key", id) \
+ .key_down("a") \
+ .key_up("a") \
+ .perform()
+ """
+
+ def __init__(self, marionette, action_type, input_id, pointer_params=None):
+ self.marionette = marionette
+ self._actions = []
+ self._id = input_id
+ self._pointer_params = pointer_params
+ self._type = action_type
+
+ @property
+ def dict(self):
+ d = {
+ "type": self._type,
+ "id": self._id,
+ "actions": self._actions,
+ }
+ if self._pointer_params is not None:
+ d["parameters"] = self._pointer_params
+ return d
+
+ def perform(self):
+ """Perform all queued actions."""
+ self.marionette.actions.perform([self.dict])
+
+ def _key_action(self, subtype, value):
+ self._actions.append({"type": subtype, "value": value})
+
+ def _pointer_action(self, subtype, button):
+ self._actions.append({"type": subtype, "button": button})
+
+ def pause(self, duration):
+ self._actions.append({"type": "pause", "duration": duration})
+ return self
+
+ def pointer_move(self, x, y, duration=None, origin=None):
+ """Queue a pointerMove action.
+
+ :param x: Destination x-axis coordinate of pointer in CSS pixels.
+ :param y: Destination y-axis coordinate of pointer in CSS pixels.
+ :param duration: Number of milliseconds over which to distribute the
+ move. If None, remote end defaults to 0.
+ :param origin: Origin of coordinates, either "viewport", "pointer" or
+ an Element. If None, remote end defaults to "viewport".
+ """
+ action = {"type": "pointerMove", "x": x, "y": y}
+ if duration is not None:
+ action["duration"] = duration
+ if origin is not None:
+ if isinstance(origin, HTMLElement):
+ action["origin"] = {origin.kind: origin.id}
+ else:
+ action["origin"] = origin
+ self._actions.append(action)
+ return self
+
+ def pointer_up(self, button=MouseButton.LEFT):
+ """Queue a pointerUp action for `button`.
+
+ :param button: Pointer button to perform action with.
+ Default: 0, which represents main device button.
+ """
+ self._pointer_action("pointerUp", button)
+ return self
+
+ def pointer_down(self, button=MouseButton.LEFT):
+ """Queue a pointerDown action for `button`.
+
+ :param button: Pointer button to perform action with.
+ Default: 0, which represents main device button.
+ """
+ self._pointer_action("pointerDown", button)
+ return self
+
+ def click(self, element=None, button=MouseButton.LEFT):
+ """Queue a click with the specified button.
+
+ If an element is given, move the pointer to that element first,
+ otherwise click current pointer coordinates.
+
+ :param element: Optional element to click.
+ :param button: Integer representing pointer button to perform action
+ with. Default: 0, which represents main device button.
+ """
+ if element:
+ self.pointer_move(0, 0, origin=element)
+ return self.pointer_down(button).pointer_up(button)
+
+ def key_down(self, value):
+ """Queue a keyDown action for `value`.
+
+ :param value: Single character to perform key action with.
+ """
+ self._key_action("keyDown", value)
+ return self
+
+ def key_up(self, value):
+ """Queue a keyUp action for `value`.
+
+ :param value: Single character to perform key action with.
+ """
+ self._key_action("keyUp", value)
+ return self
+
+ def send_keys(self, keys):
+ """Queue a keyDown and keyUp action for each character in `keys`.
+
+ :param keys: String of keys to perform key actions with.
+ """
+ for c in keys:
+ self.key_down(c)
+ self.key_up(c)
+ return self
+
+
+class Actions(object):
+ def __init__(self, marionette):
+ self.marionette = marionette
+
+ def perform(self, actions=None):
+ """Perform actions by tick from each action sequence in `actions`.
+
+ :param actions: List of input source action sequences. A single action
+ sequence may be created with the help of
+ ``ActionSequence.dict``.
+ """
+ body = {"actions": [] if actions is None else actions}
+ return self.marionette._send_message("WebDriver:PerformActions", body)
+
+ def release(self):
+ return self.marionette._send_message("WebDriver:ReleaseActions")
+
+ def sequence(self, *args, **kwargs):
+ """Return an empty ActionSequence of the designated type.
+
+ See ActionSequence for parameter list.
+ """
+ return ActionSequence(self.marionette, *args, **kwargs)
+
+
+class HTMLElement(object):
+ """Represents a DOM Element."""
+
+ identifiers = (FRAME_KEY, WINDOW_KEY, WEB_ELEMENT_KEY)
+
+ def __init__(self, marionette, id, kind=WEB_ELEMENT_KEY):
+ self.marionette = marionette
+ assert id is not None
+ self.id = id
+ self.kind = kind
+
+ def __str__(self):
+ return self.id
+
+ def __eq__(self, other_element):
+ return self.id == other_element.id
+
+ def __hash__(self):
+ # pylint --py3k: W1641
+ return hash(self.id)
+
+ def find_element(self, method, target):
+ """Returns an ``HTMLElement`` instance that matches the specified
+ method and target, relative to the current element.
+
+ For more details on this function, see the
+ :func:`~marionette_driver.marionette.Marionette.find_element` method
+ in the Marionette class.
+ """
+ return self.marionette.find_element(method, target, self.id)
+
+ def find_elements(self, method, target):
+ """Returns a list of all ``HTMLElement`` instances that match the
+ specified method and target in the current context.
+
+ For more details on this function, see the
+ :func:`~marionette_driver.marionette.Marionette.find_elements` method
+ in the Marionette class.
+ """
+ return self.marionette.find_elements(method, target, self.id)
+
+ def get_attribute(self, name):
+ """Returns the requested attribute, or None if no attribute
+ is set.
+ """
+ body = {"id": self.id, "name": name}
+ return self.marionette._send_message(
+ "WebDriver:GetElementAttribute", body, key="value"
+ )
+
+ def get_property(self, name):
+ """Returns the requested property, or None if the property is
+ not set.
+ """
+ try:
+ body = {"id": self.id, "name": name}
+ return self.marionette._send_message(
+ "WebDriver:GetElementProperty", body, key="value"
+ )
+ except errors.UnknownCommandException:
+ # Keep backward compatibility for code which uses get_attribute() to
+ # also retrieve element properties.
+ # Remove when Firefox 55 is stable.
+ return self.get_attribute(name)
+
+ def click(self):
+ """Simulates a click on the element."""
+ self.marionette._send_message("WebDriver:ElementClick", {"id": self.id})
+
+ def tap(self, x=None, y=None):
+ """Simulates a set of tap events on the element.
+
+ :param x: X coordinate of tap event. If not given, default to
+ the centre of the element.
+ :param y: Y coordinate of tap event. If not given, default to
+ the centre of the element.
+ """
+ body = {"id": self.id, "x": x, "y": y}
+ self.marionette._send_message("Marionette:SingleTap", body)
+
+ @property
+ def text(self):
+ """Returns the visible text of the element, and its child elements."""
+ body = {"id": self.id}
+ return self.marionette._send_message(
+ "WebDriver:GetElementText", body, key="value"
+ )
+
+ def send_keys(self, *strings):
+ """Sends the string via synthesized keypresses to the element.
+ If an array is passed in like `marionette.send_keys(Keys.SHIFT, "a")` it
+ will be joined into a string.
+ If an integer is passed in like `marionette.send_keys(1234)` it will be
+ coerced into a string.
+ """
+ keys = Marionette.convert_keys(*strings)
+ self.marionette._send_message(
+ "WebDriver:ElementSendKeys", {"id": self.id, "text": keys}
+ )
+
+ def clear(self):
+ """Clears the input of the element."""
+ self.marionette._send_message("WebDriver:ElementClear", {"id": self.id})
+
+ def is_selected(self):
+ """Returns True if the element is selected."""
+ body = {"id": self.id}
+ return self.marionette._send_message(
+ "WebDriver:IsElementSelected", body, key="value"
+ )
+
+ def is_enabled(self):
+ """This command will return False if all the following criteria
+ are met otherwise return True:
+
+ * A form control is disabled.
+ * A ``HTMLElement`` has a disabled boolean attribute.
+ """
+ body = {"id": self.id}
+ return self.marionette._send_message(
+ "WebDriver:IsElementEnabled", body, key="value"
+ )
+
+ def is_displayed(self):
+ """Returns True if the element is displayed, False otherwise."""
+ body = {"id": self.id}
+ return self.marionette._send_message(
+ "WebDriver:IsElementDisplayed", body, key="value"
+ )
+
+ @property
+ def tag_name(self):
+ """The tag name of the element."""
+ body = {"id": self.id}
+ return self.marionette._send_message(
+ "WebDriver:GetElementTagName", body, key="value"
+ )
+
+ @property
+ def rect(self):
+ """Gets the element's bounding rectangle.
+
+ This will return a dictionary with the following:
+
+ * x and y represent the top left coordinates of the ``HTMLElement``
+ relative to top left corner of the document.
+ * height and the width will contain the height and the width
+ of the DOMRect of the ``HTMLElement``.
+ """
+ return self.marionette._send_message(
+ "WebDriver:GetElementRect", {"id": self.id}
+ )
+
+ def value_of_css_property(self, property_name):
+ """Gets the value of the specified CSS property name.
+
+ :param property_name: Property name to get the value of.
+ """
+ body = {"id": self.id, "propertyName": property_name}
+ return self.marionette._send_message(
+ "WebDriver:GetElementCSSValue", body, key="value"
+ )
+
+ @property
+ def shadow_root(self):
+ """Gets the shadow root of the current element"""
+ return self.marionette._send_message(
+ "WebDriver:GetShadowRoot", {"id": self.id}, key="value"
+ )
+
+ @classmethod
+ def _from_json(cls, json, marionette):
+ if isinstance(json, dict):
+ if WEB_ELEMENT_KEY in json:
+ return cls(marionette, json[WEB_ELEMENT_KEY], WEB_ELEMENT_KEY)
+ elif FRAME_KEY in json:
+ return cls(marionette, json[FRAME_KEY], FRAME_KEY)
+ elif WINDOW_KEY in json:
+ return cls(marionette, json[WINDOW_KEY], WINDOW_KEY)
+ raise ValueError("Unrecognised web element")
+
+
+class ShadowRoot(object):
+ """A Class to handling Shadow Roots"""
+
+ identifiers = (WEB_SHADOW_ROOT_KEY,)
+
+ def __init__(self, marionette, id, kind=WEB_SHADOW_ROOT_KEY):
+ self.marionette = marionette
+ assert id is not None
+ self.id = id
+ self.kind = kind
+
+ def __str__(self):
+ return self.id
+
+ def __eq__(self, other_element):
+ return self.id == other_element.id
+
+ def __hash__(self):
+ # pylint --py3k: W1641
+ return hash(self.id)
+
+ @classmethod
+ def _from_json(cls, json, marionette):
+ if isinstance(json, dict):
+ if WEB_SHADOW_ROOT_KEY in json:
+ return cls(marionette, json[WEB_SHADOW_ROOT_KEY])
+ raise ValueError("Unrecognised shadow root")
+
+
+class Alert(object):
+ """A class for interacting with alerts.
+
+ ::
+
+ Alert(marionette).accept()
+ Alert(marionette).dismiss()
+ """
+
+ def __init__(self, marionette):
+ self.marionette = marionette
+
+ def accept(self):
+ """Accept a currently displayed modal dialog."""
+ self.marionette._send_message("WebDriver:AcceptAlert")
+
+ def dismiss(self):
+ """Dismiss a currently displayed modal dialog."""
+ self.marionette._send_message("WebDriver:DismissAlert")
+
+ @property
+ def text(self):
+ """Return the currently displayed text in a tab modal."""
+ return self.marionette._send_message("WebDriver:GetAlertText", key="value")
+
+ def send_keys(self, *string):
+ """Send keys to the currently displayed text input area in an open
+ tab modal dialog."""
+ self.marionette._send_message(
+ "WebDriver:SendAlertText", {"text": Marionette.convert_keys(*string)}
+ )
+
+
+class Marionette(object):
+ """Represents a Marionette connection to a browser or device."""
+
+ CONTEXT_CHROME = "chrome" # non-browser content: windows, dialogs, etc.
+ CONTEXT_CONTENT = "content" # browser content: iframes, divs, etc.
+ DEFAULT_STARTUP_TIMEOUT = 120
+ DEFAULT_SHUTDOWN_TIMEOUT = (
+ 70 # By default Firefox will kill hanging threads after 60s
+ )
+
+ # Bug 1336953 - Until we can remove the socket timeout parameter it has to be
+ # set a default value which is larger than the longest timeout as defined by the
+ # WebDriver spec. In that case its 300s for page load. Also add another minute
+ # so that slow builds have enough time to send the timeout error to the client.
+ DEFAULT_SOCKET_TIMEOUT = 360
+
+ def __init__(
+ self,
+ host="127.0.0.1",
+ port=2828,
+ app=None,
+ bin=None,
+ baseurl=None,
+ socket_timeout=None,
+ startup_timeout=None,
+ **instance_args
+ ):
+ """Construct a holder for the Marionette connection.
+
+ Remember to call ``start_session`` in order to initiate the
+ connection and start a Marionette session.
+
+ :param host: Host where the Marionette server listens.
+ Defaults to 127.0.0.1.
+ :param port: Port where the Marionette server listens.
+ Defaults to port 2828.
+ :param baseurl: Where to look for files served from Marionette's
+ www directory.
+ :param socket_timeout: Timeout for Marionette socket operations.
+ :param startup_timeout: Seconds to wait for a connection with
+ binary.
+ :param bin: Path to browser binary. If any truthy value is given
+ this will attempt to start a Gecko instance with the specified
+ `app`.
+ :param app: Type of ``instance_class`` to use for managing app
+ instance. See ``marionette_driver.geckoinstance``.
+ :param instance_args: Arguments to pass to ``instance_class``.
+ """
+ self.host = "127.0.0.1" # host
+ if int(port) == 0:
+ port = Marionette.check_port_available(port)
+ self.port = self.local_port = int(port)
+ self.bin = bin
+ self.client = None
+ self.instance = None
+ self.requested_capabilities = None
+ self.session = None
+ self.session_id = None
+ self.process_id = None
+ self.profile = None
+ self.window = None
+ self.chrome_window = None
+ self.baseurl = baseurl
+ self._test_name = None
+ self.crashed = 0
+ self.is_shutting_down = False
+ self.cleanup_ran = False
+
+ if socket_timeout is None:
+ self.socket_timeout = self.DEFAULT_SOCKET_TIMEOUT
+ else:
+ self.socket_timeout = float(socket_timeout)
+
+ if startup_timeout is None:
+ self.startup_timeout = self.DEFAULT_STARTUP_TIMEOUT
+ else:
+ self.startup_timeout = int(startup_timeout)
+
+ self.shutdown_timeout = self.DEFAULT_SHUTDOWN_TIMEOUT
+
+ if self.bin:
+ self.instance = GeckoInstance.create(
+ app, host=self.host, port=self.port, bin=self.bin, **instance_args
+ )
+ self.start_binary(self.startup_timeout)
+
+ self.actions = Actions(self)
+ self.timeout = Timeouts(self)
+
+ @property
+ def profile_path(self):
+ if self.instance and self.instance.profile:
+ return self.instance.profile.profile
+
+ def start_binary(self, timeout):
+ try:
+ self.check_port_available(self.port, host=self.host)
+ except socket.error:
+ _, value, tb = sys.exc_info()
+ msg = "Port {}:{} is unavailable ({})".format(self.host, self.port, value)
+ reraise(IOError, IOError(msg), tb)
+
+ try:
+ self.instance.start()
+ self.raise_for_port(timeout=timeout)
+ except socket.timeout:
+ # Something went wrong with starting up Marionette server. Given
+ # that the process will not quit itself, force a shutdown immediately.
+ self.cleanup()
+
+ msg = (
+ "Process killed after {}s because no connection to Marionette "
+ "server could be established. Check gecko.log for errors"
+ )
+ reraise(IOError, IOError(msg.format(timeout)), sys.exc_info()[2])
+
+ def cleanup(self):
+ if self.session is not None:
+ try:
+ self.delete_session()
+ except (errors.MarionetteException, IOError):
+ # These exceptions get thrown if the Marionette server
+ # hit an exception/died or the connection died. We can
+ # do no further server-side cleanup in this case.
+ pass
+
+ if self.instance:
+ # stop application and, if applicable, stop emulator
+ self.instance.close(clean=True)
+ if self.instance.unresponsive_count >= 3:
+ raise errors.UnresponsiveInstanceException(
+ "Application clean-up has failed >2 consecutive times."
+ )
+
+ self.cleanup_ran = True
+
+ def __del__(self):
+ if not self.cleanup_ran:
+ self.cleanup()
+
+ @staticmethod
+ def check_port_available(port, host=""):
+ """Check if "host:port" is available.
+
+ Raise socket.error if port is not available.
+ """
+ port = int(port)
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ s.bind((host, port))
+ port = s.getsockname()[1]
+ finally:
+ s.close()
+ return port
+
+ def raise_for_port(self, timeout=None, check_process_status=True):
+ """Raise socket.timeout if no connection can be established.
+
+ :param timeout: Optional timeout in seconds for the server to be ready.
+ :param check_process_status: Optional, if `True` the process will be
+ continuously checked if it has exited, and the connection
+ attempt will be aborted.
+ """
+ if timeout is None:
+ timeout = self.startup_timeout
+
+ runner = None
+ if self.instance is not None:
+ runner = self.instance.runner
+
+ poll_interval = 0.1
+ starttime = datetime.datetime.now()
+ timeout_time = starttime + datetime.timedelta(seconds=timeout)
+
+ client = transport.TcpTransport(self.host, self.port, 0.5)
+
+ connected = False
+ while datetime.datetime.now() < timeout_time:
+ # If the instance we want to connect to is not running return immediately
+ if check_process_status and runner is not None and not runner.is_running():
+ break
+
+ try:
+ client.connect()
+ return True
+ except socket.error:
+ pass
+ finally:
+ client.close()
+
+ time.sleep(poll_interval)
+
+ if not connected:
+ # There might have been a startup crash of the application
+ if runner is not None and self.check_for_crash() > 0:
+ raise IOError("Process crashed (Exit code: {})".format(runner.wait(0)))
+
+ raise socket.timeout(
+ "Timed out waiting for connection on {0}:{1}!".format(
+ self.host, self.port
+ )
+ )
+
+ @do_process_check
+ def _send_message(self, name, params=None, key=None):
+ """Send a blocking message to the server.
+
+ Marionette provides an asynchronous, non-blocking interface and
+ this attempts to paper over this by providing a synchronous API
+ to the user.
+
+ :param name: Requested command key.
+ :param params: Optional dictionary of key/value arguments.
+ :param key: Optional key to extract from response.
+
+ :returns: Full response from the server, or if `key` is given,
+ the value of said key in the response.
+ """
+ if not self.session_id and name != "WebDriver:NewSession":
+ raise errors.InvalidSessionIdException("Please start a session")
+
+ try:
+ msg = self.client.request(name, params)
+ except IOError:
+ self.delete_session(send_request=False)
+ raise
+
+ res, err = msg.result, msg.error
+ if err:
+ self._handle_error(err)
+
+ if key is not None:
+ return self._from_json(res.get(key))
+ else:
+ return self._from_json(res)
+
+ def _handle_error(self, obj):
+ error = obj["error"]
+ message = obj["message"]
+ stacktrace = obj["stacktrace"]
+
+ raise errors.lookup(error)(message, stacktrace=stacktrace)
+
+ def check_for_crash(self):
+ """Check if the process crashed.
+
+ :returns: True, if a crash happened since the method has been called the last time.
+ """
+ crash_count = 0
+
+ if self.instance:
+ name = self.test_name or "marionette.py"
+ crash_count = self.instance.runner.check_for_crashes(test_name=name)
+ self.crashed = self.crashed + crash_count
+
+ return crash_count > 0
+
+ def _handle_socket_failure(self):
+ """Handle socket failures for the currently connected application.
+
+ If the application crashed then clean-up internal states, or in case of a content
+ crash also kill the process. If there are other reasons for a socket failure,
+ wait for the process to shutdown itself, or force kill it.
+
+ Please note that the method expects an exception to be handled on the current stack
+ frame, and is only called via the `@do_process_check` decorator.
+
+ """
+ exc_cls, exc, tb = sys.exc_info()
+
+ # If the application hasn't been launched by Marionette no further action can be done.
+ # In such cases we simply re-throw the exception.
+ if not self.instance:
+ reraise(exc_cls, exc, tb)
+
+ else:
+ # Somehow the socket disconnected. Give the application some time to shutdown
+ # itself before killing the process.
+ returncode = self.instance.runner.wait(timeout=self.shutdown_timeout)
+
+ if returncode is None:
+ message = (
+ "Process killed because the connection to Marionette server is "
+ "lost. Check gecko.log for errors"
+ )
+ # This will force-close the application without sending any other message.
+ self.cleanup()
+ else:
+ # If Firefox quit itself check if there was a crash
+ crash_count = self.check_for_crash()
+
+ if crash_count > 0:
+ # SIGUSR1 indicates a forced shutdown due to a content process crash
+ if returncode == 245:
+ message = "Content process crashed"
+ else:
+ message = "Process crashed (Exit code: {returncode})"
+ else:
+ message = (
+ "Process has been unexpectedly closed (Exit code: {returncode})"
+ )
+
+ self.delete_session(send_request=False)
+
+ message += " (Reason: {reason})"
+
+ reraise(
+ IOError, IOError(message.format(returncode=returncode, reason=exc)), tb
+ )
+
+ @staticmethod
+ def convert_keys(*string):
+ typing = []
+ for val in string:
+ if isinstance(val, Keys):
+ typing.append(val)
+ elif isinstance(val, int):
+ val = str(val)
+ for i in range(len(val)):
+ typing.append(val[i])
+ else:
+ for i in range(len(val)):
+ typing.append(val[i])
+ return "".join(typing)
+
+ def clear_pref(self, pref):
+ """Clear the user-defined value from the specified preference.
+
+ :param pref: Name of the preference.
+ """
+ with self.using_context(self.CONTEXT_CHROME):
+ self.execute_script(
+ """
+ const { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+ );
+ Preferences.reset(arguments[0]);
+ """,
+ script_args=(pref,),
+ )
+
+ def get_pref(self, pref, default_branch=False, value_type="unspecified"):
+ """Get the value of the specified preference.
+
+ :param pref: Name of the preference.
+ :param default_branch: Optional, if `True` the preference value will be read
+ from the default branch. Otherwise the user-defined
+ value if set is returned. Defaults to `False`.
+ :param value_type: Optional, XPCOM interface of the pref's complex value.
+ Possible values are: `nsIFile` and
+ `nsIPrefLocalizedString`.
+
+ Usage example::
+
+ marionette.get_pref("browser.tabs.warnOnClose")
+
+ """
+ with self.using_context(self.CONTEXT_CHROME):
+ pref_value = self.execute_script(
+ """
+ const { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+ );
+
+ let pref = arguments[0];
+ let defaultBranch = arguments[1];
+ let valueType = arguments[2];
+
+ prefs = new Preferences({defaultBranch: defaultBranch});
+ return prefs.get(pref, null, Components.interfaces[valueType]);
+ """,
+ script_args=(pref, default_branch, value_type),
+ )
+ return pref_value
+
+ def set_pref(self, pref, value, default_branch=False):
+ """Set the value of the specified preference.
+
+ :param pref: Name of the preference.
+ :param value: The value to set the preference to. If the value is None,
+ reset the preference to its default value. If no default
+ value exists, the preference will cease to exist.
+ :param default_branch: Optional, if `True` the preference value will
+ be written to the default branch, and will remain until
+ the application gets restarted. Otherwise a user-defined
+ value is set. Defaults to `False`.
+
+ Usage example::
+
+ marionette.set_pref("browser.tabs.warnOnClose", True)
+
+ """
+ with self.using_context(self.CONTEXT_CHROME):
+ if value is None:
+ self.clear_pref(pref)
+ return
+
+ self.execute_script(
+ """
+ const { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+ );
+
+ let pref = arguments[0];
+ let value = arguments[1];
+ let defaultBranch = arguments[2];
+
+ prefs = new Preferences({defaultBranch: defaultBranch});
+ prefs.set(pref, value);
+ """,
+ script_args=(pref, value, default_branch),
+ )
+
+ def set_prefs(self, prefs, default_branch=False):
+ """Set the value of a list of preferences.
+
+ :param prefs: A dict containing one or more preferences and their values
+ to be set. See :func:`set_pref` for further details.
+ :param default_branch: Optional, if `True` the preference value will
+ be written to the default branch, and will remain until
+ the application gets restarted. Otherwise a user-defined
+ value is set. Defaults to `False`.
+
+ Usage example::
+
+ marionette.set_prefs({"browser.tabs.warnOnClose": True})
+
+ """
+ for pref, value in prefs.items():
+ self.set_pref(pref, value, default_branch=default_branch)
+
+ @contextmanager
+ def using_prefs(self, prefs, default_branch=False):
+ """Set preferences for code executed in a `with` block, and restores them on exit.
+
+ :param prefs: A dict containing one or more preferences and their values
+ to be set. See :func:`set_prefs` for further details.
+ :param default_branch: Optional, if `True` the preference value will
+ be written to the default branch, and will remain until
+ the application gets restarted. Otherwise a user-defined
+ value is set. Defaults to `False`.
+
+ Usage example::
+
+ with marionette.using_prefs({"browser.tabs.warnOnClose": True}):
+ # ... do stuff ...
+
+ """
+ original_prefs = {p: self.get_pref(p) for p in prefs}
+ self.set_prefs(prefs, default_branch=default_branch)
+
+ try:
+ yield
+ finally:
+ self.set_prefs(original_prefs, default_branch=default_branch)
+
+ @do_process_check
+ def enforce_gecko_prefs(self, prefs):
+ """Checks if the running instance has the given prefs. If not,
+ it will kill the currently running instance, and spawn a new
+ instance with the requested preferences.
+
+ :param prefs: A dictionary whose keys are preference names.
+ """
+ if not self.instance:
+ raise errors.MarionetteException(
+ "enforce_gecko_prefs() can only be called "
+ "on Gecko instances launched by Marionette"
+ )
+ pref_exists = True
+ with self.using_context(self.CONTEXT_CHROME):
+ for pref, value in six.iteritems(prefs):
+ if type(value) is not str:
+ value = json.dumps(value)
+ pref_exists = self.execute_script(
+ """
+ let prefInterface = Components.classes["@mozilla.org/preferences-service;1"]
+ .getService(Components.interfaces.nsIPrefBranch);
+ let pref = '{0}';
+ let value = '{1}';
+ let type = prefInterface.getPrefType(pref);
+ switch(type) {{
+ case prefInterface.PREF_STRING:
+ return value == prefInterface.getCharPref(pref).toString();
+ case prefInterface.PREF_BOOL:
+ return value == prefInterface.getBoolPref(pref).toString();
+ case prefInterface.PREF_INT:
+ return value == prefInterface.getIntPref(pref).toString();
+ case prefInterface.PREF_INVALID:
+ return false;
+ }}
+ """.format(
+ pref, value
+ )
+ )
+ if not pref_exists:
+ break
+
+ if not pref_exists:
+ context = self._send_message("Marionette:GetContext", key="value")
+ self.delete_session()
+ self.instance.restart(prefs)
+ self.raise_for_port()
+ self.start_session(self.requested_capabilities)
+
+ # Restore the context as used before the restart
+ self.set_context(context)
+
+ def _request_in_app_shutdown(self, flags=None, safe_mode=False):
+ """Attempt to quit the currently running instance from inside the
+ application. If shutdown is prevented by some component the quit
+ will be forced.
+
+ This method effectively calls `Services.startup.quit` in Gecko.
+ Possible flag values are listed at https://bit.ly/3IYcjYi.
+
+ :param flags: Optional additional quit masks to include.
+
+ :param safe_mode: Optional flag to indicate that the application has to
+ be restarted in safe mode.
+
+ :returns: A dictionary containing details of the application shutdown.
+ The `cause` property reflects the reason, and `forced` indicates
+ that something prevented the shutdown and the application had
+ to be forced to shutdown.
+
+ :throws InvalidArgumentException: If there are multiple
+ `shutdown_flags` ending with `"Quit"`.
+ """
+ body = {}
+ if flags is not None:
+ body["flags"] = list(
+ flags,
+ )
+ if safe_mode:
+ body["safeMode"] = safe_mode
+
+ return self._send_message("Marionette:Quit", body)
+
+ @do_process_check
+ def quit(self, clean=False, in_app=True, callback=None):
+ """
+ By default this method will trigger a normal shutdown of the currently running instance.
+ But it can also be used to force terminate the process.
+
+ This command will delete the active marionette session. It also allows
+ manipulation of eg. the profile data while the application is not running.
+ To start the application again, :func:`start_session` has to be called.
+
+ :param clean: If True a new profile will be used after the next start of
+ the application. Note that the in_app initiated quit always
+ maintains the same profile.
+
+ :param in_app: If True, marionette will cause a quit from within the
+ application. Otherwise the application will be restarted
+ immediately by killing the process.
+
+ :param callback: If provided and `in_app` is True, the callback will
+ be used to trigger the shutdown.
+
+ :returns: A dictionary containing details of the application shutdown.
+ The `cause` property reflects the reason, and `forced` indicates
+ that something prevented the shutdown and the application had
+ to be forced to shutdown.
+ """
+ if not self.instance:
+ raise errors.MarionetteException(
+ "quit() can only be called " "on Gecko instances launched by Marionette"
+ )
+
+ quit_details = {"cause": "shutdown", "forced": False}
+
+ if in_app:
+ if clean:
+ raise ValueError(
+ "An in_app restart cannot be triggered with the clean flag set"
+ )
+
+ if callback is not None and not callable(callback):
+ raise ValueError(
+ "Specified callback '{}' is not callable".format(callback)
+ )
+
+ # Block Marionette from accepting new connections
+ self._send_message("Marionette:AcceptConnections", {"value": False})
+
+ try:
+ self.is_shutting_down = True
+ if callback is not None:
+ callback()
+ quit_details["in_app"] = True
+ else:
+ quit_details = self._request_in_app_shutdown()
+
+ except IOError:
+ # A possible IOError should be ignored at this point, given that
+ # quit() could have been called inside of `using_context`,
+ # which wants to reset the context but fails sending the message.
+ pass
+
+ except Exception:
+ # For any other error assume the application is not going to shutdown.
+ # As such allow Marionette to accept new connections again.
+ self.is_shutting_down = False
+ self._send_message("Marionette:AcceptConnections", {"value": True})
+ raise
+
+ try:
+ self.delete_session(send_request=False)
+
+ # Try to wait for the process to end itself before force-closing it.
+ returncode = self.instance.runner.wait(timeout=self.shutdown_timeout)
+ if returncode is None:
+ self.cleanup()
+
+ message = "Process still running {}s after quit request"
+ raise IOError(message.format(self.shutdown_timeout))
+
+ finally:
+ self.is_shutting_down = False
+
+ else:
+ self.delete_session(send_request=False)
+ self.instance.close(clean=clean)
+
+ quit_details.update({"in_app": False, "forced": True})
+
+ if quit_details.get("cause") not in (None, "shutdown"):
+ raise errors.MarionetteException(
+ "Unexpected shutdown reason '{}' for "
+ "quitting the process.".format(quit_details["cause"])
+ )
+
+ return quit_details
+
+ @do_process_check
+ def restart(
+ self, callback=None, clean=False, in_app=True, safe_mode=False, silent=False
+ ):
+ """
+ By default this method will restart the currently running instance by using the same
+ profile. But it can also be forced to terminate the currently running instance, and
+ to spawn a new instance with the same or different profile.
+
+ :param callback: If provided and `in_app` is True, the callback will be
+ used to trigger the restart.
+
+ :param clean: If True a new profile will be used after the restart. Note
+ that the in_app initiated restart always maintains the same
+ profile.
+
+ :param in_app: If True, marionette will cause a restart from within the
+ application. Otherwise the application will be restarted
+ immediately by killing the process.
+
+ :param safe_mode: Optional flag to indicate that the application has to
+ be restarted in safe mode.
+
+ :param silent: Optional flag to indicate that the application should
+ not open any window after a restart. Note that this flag is only
+ supported on MacOS and requires "in_app" to be True.
+
+ :returns: A dictionary containing details of the application restart.
+ The `cause` property reflects the reason, and `forced` indicates
+ that something prevented the shutdown and the application had
+ to be forced to shutdown.
+ """
+ if not self.instance:
+ raise errors.MarionetteException(
+ "restart() can only be called "
+ "on Gecko instances launched by Marionette"
+ )
+
+ context = self._send_message("Marionette:GetContext", key="value")
+ restart_details = {"cause": "restart", "forced": False}
+
+ # Safe mode and the silent flag require an in_app restart.
+ if (safe_mode or silent) and not in_app:
+ raise ValueError("An in_app restart is required for safe or silent mode")
+
+ if in_app:
+ if clean:
+ raise ValueError(
+ "An in_app restart cannot be triggered with the clean flag set"
+ )
+
+ if callback is not None and not callable(callback):
+ raise ValueError(
+ "Specified callback '{}' is not callable".format(callback)
+ )
+
+ # Block Marionette from accepting new connections
+ self._send_message("Marionette:AcceptConnections", {"value": False})
+
+ try:
+ self.is_shutting_down = True
+ if callback is not None:
+ callback()
+ restart_details["in_app"] = True
+ else:
+ flags = ["eRestart"]
+ if silent:
+ flags.append("eSilently")
+
+ try:
+ restart_details = self._request_in_app_shutdown(
+ flags=flags, safe_mode=safe_mode
+ )
+ except Exception as e:
+ self._send_message(
+ "Marionette:AcceptConnections", {"value": True}
+ )
+ raise e
+
+ except IOError:
+ # A possible IOError should be ignored at this point, given that
+ # restart() could have been called inside of `using_context`,
+ # which wants to reset the context but fails sending the message.
+ pass
+
+ timeout_restart = self.shutdown_timeout + self.startup_timeout
+ try:
+ # Wait for a new Marionette connection to appear while the
+ # process restarts itself.
+ self.raise_for_port(timeout=timeout_restart, check_process_status=False)
+ except socket.timeout:
+ exc_cls, _, tb = sys.exc_info()
+
+ if self.instance.runner.returncode is None:
+ # The process is still running, which means the shutdown
+ # request was not correct or the application ignored it.
+ # Allow Marionette to accept connections again.
+ self._send_message("Marionette:AcceptConnections", {"value": True})
+
+ message = "Process still running {}s after restart request"
+ reraise(exc_cls, exc_cls(message.format(timeout_restart)), tb)
+
+ else:
+ # The process shutdown but didn't start again.
+ self.cleanup()
+ msg = "Process unexpectedly quit without restarting (exit code: {})"
+ reraise(
+ exc_cls,
+ exc_cls(msg.format(self.instance.runner.returncode)),
+ tb,
+ )
+
+ finally:
+ self.is_shutting_down = False
+
+ self.delete_session(send_request=False)
+
+ else:
+ self.delete_session()
+ self.instance.restart(clean=clean)
+ self.raise_for_port(timeout=self.DEFAULT_STARTUP_TIMEOUT)
+
+ restart_details.update({"in_app": False, "forced": True})
+
+ if restart_details.get("cause") not in (None, "restart"):
+ raise errors.MarionetteException(
+ "Unexpected shutdown reason '{}' for "
+ "restarting the process".format(restart_details["cause"])
+ )
+
+ self.start_session(self.requested_capabilities)
+ # Restore the context as used before the restart
+ self.set_context(context)
+
+ if in_app and self.process_id:
+ # In some cases Firefox restarts itself by spawning into a new process group.
+ # As long as mozprocess cannot track that behavior (bug 1284864) we assist by
+ # informing about the new process id.
+ self.instance.runner.process_handler.check_for_detached(self.process_id)
+
+ return restart_details
+
+ def absolute_url(self, relative_url):
+ """
+ Returns an absolute url for files served from Marionette's www directory.
+
+ :param relative_url: The url of a static file, relative to Marionette's www directory.
+ """
+ return "{0}{1}".format(self.baseurl, relative_url)
+
+ @do_process_check
+ def start_session(self, capabilities=None, timeout=None):
+ """Create a new WebDriver session.
+ This method must be called before performing any other action.
+
+ :param capabilities: An optional dictionary of
+ Marionette-recognised capabilities. It does not
+ accept a WebDriver conforming capabilities dictionary
+ (including alwaysMatch, firstMatch, desiredCapabilities,
+ or requriedCapabilities), and only recognises extension
+ capabilities that are specific to Marionette.
+ :param timeout: Optional timeout in seconds for the server to be ready.
+ :returns: A dictionary of the capabilities offered.
+ """
+ if capabilities is None:
+ capabilities = {"strictFileInteractability": True}
+ self.requested_capabilities = capabilities
+
+ if timeout is None:
+ timeout = self.startup_timeout
+
+ self.crashed = 0
+
+ if self.instance:
+ returncode = self.instance.runner.returncode
+ # We're managing a binary which has terminated. Start it again
+ # and implicitely wait for the Marionette server to be ready.
+ if returncode is not None:
+ self.start_binary(timeout)
+
+ else:
+ # In the case when Marionette doesn't manage the binary wait until
+ # its server component has been started.
+ self.raise_for_port(timeout=timeout)
+
+ self.client = transport.TcpTransport(self.host, self.port, self.socket_timeout)
+ self.protocol, _ = self.client.connect()
+
+ try:
+ resp = self._send_message("WebDriver:NewSession", capabilities)
+ except errors.UnknownException:
+ # Force closing the managed process when the session cannot be
+ # created due to global JavaScript errors.
+ exc_type, value, tb = sys.exc_info()
+ if self.instance and self.instance.runner.is_running():
+ self.instance.close()
+ reraise(exc_type, exc_type(value.message), tb)
+
+ self.session_id = resp["sessionId"]
+ self.session = resp["capabilities"]
+ self.cleanup_ran = False
+ # fallback to processId can be removed in Firefox 55
+ self.process_id = self.session.get(
+ "moz:processID", self.session.get("processId")
+ )
+ self.profile = self.session.get("moz:profile")
+
+ timeout = self.session.get("moz:shutdownTimeout")
+ if timeout is not None:
+ # pylint --py3k W1619
+ self.shutdown_timeout = timeout / 1000 + 10
+
+ return self.session
+
+ @property
+ def test_name(self):
+ return self._test_name
+
+ @test_name.setter
+ def test_name(self, test_name):
+ self._test_name = test_name
+
+ def delete_session(self, send_request=True):
+ """Close the current session and disconnect from the server.
+
+ :param send_request: Optional, if `True` a request to close the session on
+ the server side will be sent. Use `False` in case of eg. in_app restart()
+ or quit(), which trigger a deletion themselves. Defaults to `True`.
+ """
+ try:
+ if send_request:
+ try:
+ self._send_message("WebDriver:DeleteSession")
+ except errors.InvalidSessionIdException:
+ pass
+ finally:
+ self.process_id = None
+ self.profile = None
+ self.session = None
+ self.session_id = None
+ self.window = None
+
+ if self.client is not None:
+ self.client.close()
+
+ @property
+ def session_capabilities(self):
+ """A JSON dictionary representing the capabilities of the
+ current session.
+
+ """
+ return self.session
+
+ @property
+ def current_window_handle(self):
+ """Get the current window's handle.
+
+ Returns an opaque server-assigned identifier to this window
+ that uniquely identifies it within this Marionette instance.
+ This can be used to switch to this window at a later point.
+
+ :returns: unique window handle
+ :rtype: string
+ """
+ with self.using_context("content"):
+ self.window = self._send_message("WebDriver:GetWindowHandle", key="value")
+
+ return self.window
+
+ @property
+ def current_chrome_window_handle(self):
+ """Get the current chrome window's handle. Corresponds to
+ a chrome window that may itself contain tabs identified by
+ window_handles.
+
+ Returns an opaque server-assigned identifier to this window
+ that uniquely identifies it within this Marionette instance.
+ This can be used to switch to this window at a later point.
+
+ :returns: unique window handle
+ :rtype: string
+ """
+ with self.using_context("chrome"):
+ self.chrome_window = self._send_message(
+ "WebDriver:GetWindowHandle", key="value"
+ )
+
+ return self.chrome_window
+
+ def set_window_rect(self, x=None, y=None, height=None, width=None):
+ """Set the position and size of the current window.
+
+ The supplied width and height values refer to the window outerWidth
+ and outerHeight values, which include scroll bars, title bars, etc.
+
+ An error will be returned if the requested window size would result
+ in the window being in the maximised state.
+
+ :param x: x coordinate for the top left of the window
+ :param y: y coordinate for the top left of the window
+ :param width: The width to resize the window to.
+ :param height: The height to resize the window to.
+ """
+ if (x is None and y is None) and (height is None and width is None):
+ raise errors.InvalidArgumentException(
+ "x and y or height and width need values"
+ )
+
+ body = {"x": x, "y": y, "height": height, "width": width}
+ return self._send_message("WebDriver:SetWindowRect", body)
+
+ @property
+ def window_rect(self):
+ return self._send_message("WebDriver:GetWindowRect")
+
+ @property
+ def title(self):
+ """Current title of the active window."""
+ return self._send_message("WebDriver:GetTitle", key="value")
+
+ @property
+ def window_handles(self):
+ """Get list of windows in the current context.
+
+ If called in the content context it will return a list of
+ references to all available browser windows.
+
+ Each window handle is assigned by the server, and the list of
+ strings returned does not have a guaranteed ordering.
+
+ :returns: Unordered list of unique window handles as strings
+ """
+ with self.using_context("content"):
+ return self._send_message("WebDriver:GetWindowHandles")
+
+ @property
+ def chrome_window_handles(self):
+ """Get a list of currently open chrome windows.
+
+ Each window handle is assigned by the server, and the list of
+ strings returned does not have a guaranteed ordering.
+
+ :returns: Unordered list of unique chrome window handles as strings
+ """
+ with self.using_context("chrome"):
+ return self._send_message("WebDriver:GetWindowHandles")
+
+ @property
+ def page_source(self):
+ """A string representation of the DOM."""
+ return self._send_message("WebDriver:GetPageSource", key="value")
+
+ def open(self, type=None, focus=False, private=False):
+ """Open a new window, or tab based on the specified context type.
+
+ If no context type is given the application will choose the best
+ option based on tab and window support.
+
+ :param type: Type of window to be opened. Can be one of "tab" or "window"
+ :param focus: If true, the opened window will be focused
+ :param private: If true, open a private window
+
+ :returns: Dict with new window handle, and type of opened window
+ """
+ body = {"type": type, "focus": focus, "private": private}
+ return self._send_message("WebDriver:NewWindow", body)
+
+ def close(self):
+ """Close the current window, ending the session if it's the last
+ window currently open.
+
+ :returns: Unordered list of remaining unique window handles as strings
+ """
+ return self._send_message("WebDriver:CloseWindow")
+
+ def close_chrome_window(self):
+ """Close the currently selected chrome window, ending the session
+ if it's the last window open.
+
+ :returns: Unordered list of remaining unique chrome window handles as strings
+ """
+ return self._send_message("WebDriver:CloseChromeWindow")
+
+ def set_context(self, context):
+ """Sets the context that Marionette commands are running in.
+
+ :param context: Context, may be one of the class properties
+ `CONTEXT_CHROME` or `CONTEXT_CONTENT`.
+
+ Usage example::
+
+ marionette.set_context(marionette.CONTEXT_CHROME)
+ """
+ if context not in [self.CONTEXT_CHROME, self.CONTEXT_CONTENT]:
+ raise ValueError("Unknown context: {}".format(context))
+
+ self._send_message("Marionette:SetContext", {"value": context})
+
+ @contextmanager
+ def using_context(self, context):
+ """Sets the context that Marionette commands are running in using
+ a `with` statement. The state of the context on the server is
+ saved before entering the block, and restored upon exiting it.
+
+ :param context: Context, may be one of the class properties
+ `CONTEXT_CHROME` or `CONTEXT_CONTENT`.
+
+ Usage example::
+
+ with marionette.using_context(marionette.CONTEXT_CHROME):
+ # chrome scope
+ ... do stuff ...
+ """
+ scope = self._send_message("Marionette:GetContext", key="value")
+ self.set_context(context)
+ try:
+ yield
+ finally:
+ self.set_context(scope)
+
+ def switch_to_alert(self):
+ """Returns an :class:`~marionette_driver.marionette.Alert` object for
+ interacting with a currently displayed alert.
+
+ ::
+
+ alert = self.marionette.switch_to_alert()
+ text = alert.text
+ alert.accept()
+ """
+ return Alert(self)
+
+ def switch_to_window(self, handle, focus=True):
+ """Switch to the specified window; subsequent commands will be
+ directed at the new window.
+
+ :param handle: The id of the window to switch to.
+
+ :param focus: A boolean value which determins whether to focus
+ the window that we just switched to.
+ """
+ self._send_message(
+ "WebDriver:SwitchToWindow", {"handle": handle, "focus": focus}
+ )
+ self.window = handle
+
+ def switch_to_default_content(self):
+ """Switch the current context to page's default content."""
+ return self.switch_to_frame()
+
+ def switch_to_parent_frame(self):
+ """
+ Switch to the Parent Frame
+ """
+ self._send_message("WebDriver:SwitchToParentFrame")
+
+ def switch_to_frame(self, frame=None):
+ """Switch the current context to the specified frame. Subsequent
+ commands will operate in the context of the specified frame,
+ if applicable.
+
+ :param frame: A reference to the frame to switch to. This can
+ be an :class:`~marionette_driver.marionette.HTMLElement`,
+ or an integer index. If you call ``switch_to_frame`` without an
+ argument, it will switch to the top-level frame.
+ """
+ body = {}
+ if isinstance(frame, HTMLElement):
+ body["element"] = frame.id
+ elif frame is not None:
+ body["id"] = frame
+
+ self._send_message("WebDriver:SwitchToFrame", body)
+
+ def get_url(self):
+ """Get a string representing the current URL.
+
+ On Desktop this returns a string representation of the URL of
+ the current top level browsing context. This is equivalent to
+ document.location.href.
+
+ When in the context of the chrome, this returns the canonical
+ URL of the current resource.
+
+ :returns: string representation of URL
+ """
+ return self._send_message("WebDriver:GetCurrentURL", key="value")
+
+ def get_window_type(self):
+ """Gets the windowtype attribute of the window Marionette is
+ currently acting on.
+
+ This command only makes sense in a chrome context. You might use this
+ method to distinguish a browser window from an editor window.
+ """
+ try:
+ return self._send_message("Marionette:GetWindowType", key="value")
+ except errors.UnknownCommandException:
+ return self._send_message("getWindowType", key="value")
+
+ def navigate(self, url):
+ """Navigate to given `url`.
+
+ Navigates the current top-level browsing context's content
+ frame to the given URL and waits for the document to load or
+ the session's page timeout duration to elapse before returning.
+
+ The command will return with a failure if there is an error
+ loading the document or the URL is blocked. This can occur if
+ it fails to reach the host, the URL is malformed, the page is
+ restricted (about:* pages), or if there is a certificate issue
+ to name some examples.
+
+ The document is considered successfully loaded when the
+ `DOMContentLoaded` event on the frame element associated with the
+ `window` triggers and `document.readyState` is "complete".
+
+ In chrome context it will change the current `window`'s location
+ to the supplied URL and wait until `document.readyState` equals
+ "complete" or the page timeout duration has elapsed.
+
+ :param url: The URL to navigate to.
+ """
+ self._send_message("WebDriver:Navigate", {"url": url})
+
+ def go_back(self):
+ """Causes the browser to perform a back navigation."""
+ self._send_message("WebDriver:Back")
+
+ def go_forward(self):
+ """Causes the browser to perform a forward navigation."""
+ self._send_message("WebDriver:Forward")
+
+ def refresh(self):
+ """Causes the browser to perform to refresh the current page."""
+ self._send_message("WebDriver:Refresh")
+
+ def _to_json(self, args):
+ if isinstance(args, list) or isinstance(args, tuple):
+ wrapped = []
+ for arg in args:
+ wrapped.append(self._to_json(arg))
+ elif isinstance(args, dict):
+ wrapped = {}
+ for arg in args:
+ wrapped[arg] = self._to_json(args[arg])
+ elif type(args) == HTMLElement:
+ wrapped = {WEB_ELEMENT_KEY: args.id}
+ elif type(args) == ShadowRoot:
+ wrapped = {WEB_SHADOW_ROOT_KEY: args.id}
+ elif (
+ isinstance(args, bool)
+ or isinstance(args, six.string_types)
+ or isinstance(args, int)
+ or isinstance(args, float)
+ or args is None
+ ):
+ wrapped = args
+ return wrapped
+
+ def _from_json(self, value):
+ if isinstance(value, dict) and any(
+ k in value.keys() for k in HTMLElement.identifiers
+ ):
+ return HTMLElement._from_json(value, self)
+ elif isinstance(value, dict) and any(
+ k in value.keys() for k in ShadowRoot.identifiers
+ ):
+ return ShadowRoot._from_json(value, self)
+ elif isinstance(value, dict):
+ return {key: self._from_json(val) for key, val in value.items()}
+ elif isinstance(value, list):
+ return list(self._from_json(item) for item in value)
+ else:
+ return value
+
+ def execute_script(
+ self,
+ script,
+ script_args=(),
+ new_sandbox=True,
+ sandbox="default",
+ script_timeout=None,
+ ):
+ """Executes a synchronous JavaScript script, and returns the
+ result (or None if the script does return a value).
+
+ The script is executed in the context set by the most recent
+ :func:`set_context` call, or to the CONTEXT_CONTENT context if
+ :func:`set_context` has not been called.
+
+ :param script: A string containing the JavaScript to execute.
+ :param script_args: An interable of arguments to pass to the script.
+ :param new_sandbox: If False, preserve global variables from
+ the last execute_*script call. This is True by default, in which
+ case no globals are preserved.
+ :param sandbox: A tag referring to the sandbox you wish to use;
+ if you specify a new tag, a new sandbox will be created.
+ If you use the special tag `system`, the sandbox will
+ be created using the system principal which has elevated
+ privileges.
+ :param script_timeout: Timeout in milliseconds, overriding
+ the session's default script timeout.
+
+ Simple usage example:
+
+ ::
+
+ result = marionette.execute_script("return 1;")
+ assert result == 1
+
+ You can use the `script_args` parameter to pass arguments to the
+ script:
+
+ ::
+
+ result = marionette.execute_script("return arguments[0] + arguments[1];",
+ script_args=(2, 3,))
+ assert result == 5
+ some_element = marionette.find_element(By.ID, "someElement")
+ sid = marionette.execute_script("return arguments[0].id;", script_args=(some_element,))
+ assert some_element.get_attribute("id") == sid
+
+ Scripts wishing to access non-standard properties of the window
+ object must use window.wrappedJSObject:
+
+ ::
+
+ result = marionette.execute_script('''
+ window.wrappedJSObject.test1 = "foo";
+ window.wrappedJSObject.test2 = "bar";
+ return window.wrappedJSObject.test1 + window.wrappedJSObject.test2;
+ ''')
+ assert result == "foobar"
+
+ Global variables set by individual scripts do not persist between
+ script calls by default. If you wish to persist data between
+ script calls, you can set `new_sandbox` to False on your next call,
+ and add any new variables to a new 'global' object like this:
+
+ ::
+
+ marionette.execute_script("global.test1 = 'foo';")
+ result = self.marionette.execute_script("return global.test1;", new_sandbox=False)
+ assert result == "foo"
+
+ """
+ original_timeout = None
+ if script_timeout is not None:
+ original_timeout = self.timeout.script
+ self.timeout.script = script_timeout / 1000.0
+
+ try:
+ args = self._to_json(script_args)
+ stack = traceback.extract_stack()
+ frame = stack[-2:-1][0] # grab the second-to-last frame
+ filename = (
+ frame[0] if sys.platform == "win32" else os.path.relpath(frame[0])
+ )
+ body = {
+ "script": script.strip(),
+ "args": args,
+ "newSandbox": new_sandbox,
+ "sandbox": sandbox,
+ "line": int(frame[1]),
+ "filename": filename,
+ }
+ rv = self._send_message("WebDriver:ExecuteScript", body, key="value")
+
+ finally:
+ if script_timeout is not None:
+ self.timeout.script = original_timeout
+
+ return rv
+
+ def execute_async_script(
+ self,
+ script,
+ script_args=(),
+ new_sandbox=True,
+ sandbox="default",
+ script_timeout=None,
+ ):
+ """Executes an asynchronous JavaScript script, and returns the
+ result (or None if the script does return a value).
+
+ The script is executed in the context set by the most recent
+ :func:`set_context` call, or to the CONTEXT_CONTENT context if
+ :func:`set_context` has not been called.
+
+ :param script: A string containing the JavaScript to execute.
+ :param script_args: An interable of arguments to pass to the script.
+ :param new_sandbox: If False, preserve global variables from
+ the last execute_*script call. This is True by default,
+ in which case no globals are preserved.
+ :param sandbox: A tag referring to the sandbox you wish to use; if
+ you specify a new tag, a new sandbox will be created. If you
+ use the special tag `system`, the sandbox will be created
+ using the system principal which has elevated privileges.
+ :param script_timeout: Timeout in milliseconds, overriding
+ the session's default script timeout.
+
+ Usage example:
+
+ ::
+
+ marionette.timeout.script = 10
+ result = self.marionette.execute_async_script('''
+ // this script waits 5 seconds, and then returns the number 1
+ let [resolve] = arguments;
+ setTimeout(function() {
+ resolve(1);
+ }, 5000);
+ ''')
+ assert result == 1
+ """
+ original_timeout = None
+ if script_timeout is not None:
+ original_timeout = self.timeout.script
+ self.timeout.script = script_timeout / 1000.0
+
+ try:
+ args = self._to_json(script_args)
+ stack = traceback.extract_stack()
+ frame = stack[-2:-1][0] # grab the second-to-last frame
+ filename = (
+ frame[0] if sys.platform == "win32" else os.path.relpath(frame[0])
+ )
+ body = {
+ "script": script.strip(),
+ "args": args,
+ "newSandbox": new_sandbox,
+ "sandbox": sandbox,
+ "scriptTimeout": script_timeout,
+ "line": int(frame[1]),
+ "filename": filename,
+ }
+ rv = self._send_message("WebDriver:ExecuteAsyncScript", body, key="value")
+
+ finally:
+ if script_timeout is not None:
+ self.timeout.script = original_timeout
+
+ return rv
+
+ def find_element(self, method, target, id=None):
+ """Returns an :class:`~marionette_driver.marionette.HTMLElement`
+ instance that matches the specified method and target in the current
+ context.
+
+ An :class:`~marionette_driver.marionette.HTMLElement` instance may be
+ used to call other methods on the element, such as
+ :func:`~marionette_driver.marionette.HTMLElement.click`. If no element
+ is immediately found, the attempt to locate an element will be repeated
+ for up to the amount of time set by
+ :attr:`marionette_driver.timeout.Timeouts.implicit`. If multiple
+ elements match the given criteria, only the first is returned. If no
+ element matches, a ``NoSuchElementException`` will be raised.
+
+ :param method: The method to use to locate the element; one of:
+ "id", "name", "class name", "tag name", "css selector",
+ "link text", "partial link text" and "xpath".
+ Note that the "name", "link text" and "partial link test"
+ methods are not supported in the chrome DOM.
+ :param target: The target of the search. For example, if method =
+ "tag", target might equal "div". If method = "id", target would
+ be an element id.
+ :param id: If specified, search for elements only inside the element
+ with the specified id.
+ """
+ body = {"value": target, "using": method}
+ if id:
+ body["element"] = id
+
+ return self._send_message("WebDriver:FindElement", body, key="value")
+
+ def find_elements(self, method, target, id=None):
+ """Returns a list of all
+ :class:`~marionette_driver.marionette.HTMLElement` instances that match
+ the specified method and target in the current context.
+
+ An :class:`~marionette_driver.marionette.HTMLElement` instance may be
+ used to call other methods on the element, such as
+ :func:`~marionette_driver.marionette.HTMLElement.click`. If no element
+ is immediately found, the attempt to locate an element will be repeated
+ for up to the amount of time set by
+ :attr:`marionette_driver.timeout.Timeouts.implicit`.
+
+ :param method: The method to use to locate the elements; one
+ of: "id", "name", "class name", "tag name", "css selector",
+ "link text", "partial link text" and "xpath".
+ Note that the "name", "link text" and "partial link test"
+ methods are not supported in the chrome DOM.
+ :param target: The target of the search. For example, if method =
+ "tag", target might equal "div". If method = "id", target would be
+ an element id.
+ :param id: If specified, search for elements only inside the element
+ with the specified id.
+ """
+ body = {"value": target, "using": method}
+ if id:
+ body["element"] = id
+
+ return self._send_message("WebDriver:FindElements", body)
+
+ def get_active_element(self):
+ el_or_ref = self._send_message("WebDriver:GetActiveElement", key="value")
+ return el_or_ref
+
+ def add_cookie(self, cookie):
+ """Adds a cookie to your current session.
+
+ :param cookie: A dictionary object, with required keys - "name"
+ and "value"; optional keys - "path", "domain", "secure",
+ "expiry".
+
+ Usage example:
+
+ ::
+
+ driver.add_cookie({"name": "foo", "value": "bar"})
+ driver.add_cookie({"name": "foo", "value": "bar", "path": "/"})
+ driver.add_cookie({"name": "foo", "value": "bar", "path": "/",
+ "secure": True})
+ """
+ self._send_message("WebDriver:AddCookie", {"cookie": cookie})
+
+ def delete_all_cookies(self):
+ """Delete all cookies in the scope of the current session.
+
+ Usage example:
+
+ ::
+
+ driver.delete_all_cookies()
+ """
+ self._send_message("WebDriver:DeleteAllCookies")
+
+ def delete_cookie(self, name):
+ """Delete a cookie by its name.
+
+ :param name: Name of cookie to delete.
+
+ Usage example:
+
+ ::
+
+ driver.delete_cookie("foo")
+ """
+ self._send_message("WebDriver:DeleteCookie", {"name": name})
+
+ def get_cookie(self, name):
+ """Get a single cookie by name. Returns the cookie if found,
+ None if not.
+
+ :param name: Name of cookie to get.
+ """
+ cookies = self.get_cookies()
+ for cookie in cookies:
+ if cookie["name"] == name:
+ return cookie
+ return None
+
+ def get_cookies(self):
+ """Get all the cookies for the current domain.
+
+ This is the equivalent of calling `document.cookie` and
+ parsing the result.
+
+ :returns: A list of cookies for the current domain.
+ """
+ return self._send_message("WebDriver:GetCookies")
+
+ def save_screenshot(self, fh, element=None, full=True, scroll=True):
+ """Takes a screenhot of a web element or the current frame and
+ saves it in the filehandle.
+
+ It is a wrapper around screenshot()
+ :param fh: The filehandle to save the screenshot at.
+
+ The rest of the parameters are defined like in screenshot()
+ """
+ data = self.screenshot(element, "binary", full, scroll)
+ fh.write(data)
+
+ def screenshot(self, element=None, format="base64", full=True, scroll=True):
+ """Takes a screenshot of a web element or the current frame.
+
+ The screen capture is returned as a lossless PNG image encoded
+ as a base 64 string by default. If the `element` argument is defined the
+ capture area will be limited to the bounding box of that
+ element. Otherwise, the capture area will be the bounding box
+ of the current frame.
+
+ :param element: The element to take a screenshot of. If None, will
+ take a screenshot of the current frame.
+
+ :param format: if "base64" (the default), returns the screenshot
+ as a base64-string. If "binary", the data is decoded and
+ returned as raw binary. If "hash", the data is hashed using
+ the SHA-256 algorithm and the result is returned as a hex digest.
+
+ :param full: If True (the default), the capture area will be the
+ complete frame. Else only the viewport is captured. Only applies
+ when `element` is None.
+
+ :param scroll: When `element` is provided, scroll to it before
+ taking the screenshot (default). Otherwise, avoid scrolling
+ `element` into view.
+ """
+
+ if element:
+ element = element.id
+
+ body = {"id": element, "full": full, "hash": False, "scroll": scroll}
+ if format == "hash":
+ body["hash"] = True
+
+ data = self._send_message("WebDriver:TakeScreenshot", body, key="value")
+
+ if format == "base64" or format == "hash":
+ return data
+ elif format == "binary":
+ return base64.b64decode(data.encode("ascii"))
+ else:
+ raise ValueError(
+ "format parameter must be either 'base64'"
+ " or 'binary', not {0}".format(repr(format))
+ )
+
+ @property
+ def orientation(self):
+ """Get the current browser orientation.
+
+ Will return one of the valid primary orientation values
+ portrait-primary, landscape-primary, portrait-secondary, or
+ landscape-secondary.
+ """
+ try:
+ return self._send_message("Marionette:GetScreenOrientation", key="value")
+ except errors.UnknownCommandException:
+ return self._send_message("getScreenOrientation", key="value")
+
+ def set_orientation(self, orientation):
+ """Set the current browser orientation.
+
+ The supplied orientation should be given as one of the valid
+ orientation values. If the orientation is unknown, an error
+ will be raised.
+
+ Valid orientations are "portrait" and "landscape", which fall
+ back to "portrait-primary" and "landscape-primary"
+ respectively, and "portrait-secondary" as well as
+ "landscape-secondary".
+
+ :param orientation: The orientation to lock the screen in.
+ """
+ body = {"orientation": orientation}
+ try:
+ self._send_message("Marionette:SetScreenOrientation", body)
+ except errors.UnknownCommandException:
+ self._send_message("setScreenOrientation", body)
+
+ def minimize_window(self):
+ """Iconify the browser window currently receiving commands.
+ The action should be equivalent to the user pressing the minimize
+ button in the OS window.
+
+ Note that this command is not available on Fennec. It may also
+ not be available in certain window managers.
+
+ :returns Window rect.
+ """
+ return self._send_message("WebDriver:MinimizeWindow")
+
+ def maximize_window(self):
+ """Resize the browser window currently receiving commands.
+ The action should be equivalent to the user pressing the maximize
+ button in the OS window.
+
+
+ Note that this command is not available on Fennec. It may also
+ not be available in certain window managers.
+
+ :returns: Window rect.
+ """
+ return self._send_message("WebDriver:MaximizeWindow")
+
+ def fullscreen(self):
+ """Synchronously sets the user agent window to full screen as
+ if the user had done "View > Enter Full Screen", or restores
+ it if it is already in full screen.
+
+ :returns: Window rect.
+ """
+ return self._send_message("WebDriver:FullscreenWindow")
diff --git a/testing/marionette/client/marionette_driver/timeout.py b/testing/marionette/client/marionette_driver/timeout.py
new file mode 100644
index 0000000000..27848d0121
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/timeout.py
@@ -0,0 +1,103 @@
+# 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 . import errors
+
+DEFAULT_SCRIPT_TIMEOUT = 30
+DEFAULT_PAGE_LOAD_TIMEOUT = 300
+DEFAULT_IMPLICIT_WAIT_TIMEOUT = 0
+
+
+class Timeouts(object):
+ """Manage timeout settings in the Marionette session.
+
+ Usage::
+
+ marionette = Marionette(...)
+ marionette.start_session()
+ marionette.timeout.page_load = 10
+ marionette.timeout.page_load
+ # => 10
+
+ """
+
+ def __init__(self, marionette):
+ self._marionette = marionette
+
+ def _set(self, name, sec):
+ ms = sec * 1000
+ self._marionette._send_message("WebDriver:SetTimeouts", {name: ms})
+
+ def _get(self, name):
+ ts = self._marionette._send_message("WebDriver:GetTimeouts")
+ if name not in ts:
+ raise KeyError()
+ ms = ts[name]
+ return ms / 1000.0
+
+ @property
+ def script(self):
+ """Get the session's script timeout. This specifies the time
+ to wait for injected scripts to finished before interrupting
+ them. It is by default 30 seconds.
+
+ """
+ return self._get("script")
+
+ @script.setter
+ def script(self, sec):
+ """Set the session's script timeout. This specifies the time
+ to wait for injected scripts to finish before interrupting them.
+
+ """
+ self._set("script", sec)
+
+ @property
+ def page_load(self):
+ """Get the session's page load timeout. This specifies the time
+ to wait for the page loading to complete. It is by default 5
+ minutes (or 300 seconds).
+
+ """
+ # remove fallback when Firefox 56 is stable
+ try:
+ return self._get("pageLoad")
+ except KeyError:
+ return self._get("page load")
+
+ @page_load.setter
+ def page_load(self, sec):
+ """Set the session's page load timeout. This specifies the time
+ to wait for the page loading to complete.
+
+ """
+ # remove fallback when Firefox 56 is stable
+ try:
+ self._set("pageLoad", sec)
+ except errors.InvalidArgumentException:
+ return self._set("page load", sec)
+
+ @property
+ def implicit(self):
+ """Get the session's implicit wait timeout. This specifies the
+ time to wait for the implicit element location strategy when
+ retrieving elements. It is by default disabled (0 seconds).
+
+ """
+ return self._get("implicit")
+
+ @implicit.setter
+ def implicit(self, sec):
+ """Set the session's implicit wait timeout. This specifies the
+ time to wait for the implicit element location strategy when
+ retrieving elements.
+
+ """
+ self._set("implicit", sec)
+
+ def reset(self):
+ """Resets timeouts to their default values."""
+ self.script = DEFAULT_SCRIPT_TIMEOUT
+ self.page_load = DEFAULT_PAGE_LOAD_TIMEOUT
+ self.implicit = DEFAULT_IMPLICIT_WAIT_TIMEOUT
diff --git a/testing/marionette/client/marionette_driver/transport.py b/testing/marionette/client/marionette_driver/transport.py
new file mode 100644
index 0000000000..58b39df47b
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/transport.py
@@ -0,0 +1,408 @@
+# 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 socket
+import sys
+import time
+from threading import RLock
+
+import six
+
+
+class SocketTimeout(object):
+ def __init__(self, socket_ctx, timeout):
+ self.socket_ctx = socket_ctx
+ self.timeout = timeout
+ self.old_timeout = None
+
+ def __enter__(self):
+ self.old_timeout = self.socket_ctx.socket_timeout
+ self.socket_ctx.socket_timeout = self.timeout
+
+ def __exit__(self, *args, **kwargs):
+ self.socket_ctx.socket_timeout = self.old_timeout
+
+
+class Message(object):
+ def __init__(self, msgid):
+ self.id = msgid
+
+ def __eq__(self, other):
+ return self.id == other.id
+
+ def __ne__(self, other):
+ return not self.__eq__(other)
+
+ def __hash__(self):
+ # pylint --py3k: W1641
+ return hash(self.id)
+
+
+class Command(Message):
+ TYPE = 0
+
+ def __init__(self, msgid, name, params):
+ Message.__init__(self, msgid)
+ self.name = name
+ self.params = params
+
+ def __str__(self):
+ return "<Command id={0}, name={1}, params={2}>".format(
+ self.id, self.name, self.params
+ )
+
+ def to_msg(self):
+ msg = [Command.TYPE, self.id, self.name, self.params]
+ return json.dumps(msg)
+
+ @staticmethod
+ def from_msg(data):
+ assert data[0] == Command.TYPE
+ cmd = Command(data[1], data[2], data[3])
+ return cmd
+
+
+class Response(Message):
+ TYPE = 1
+
+ def __init__(self, msgid, error, result):
+ Message.__init__(self, msgid)
+ self.error = error
+ self.result = result
+
+ def __str__(self):
+ return "<Response id={0}, error={1}, result={2}>".format(
+ self.id, self.error, self.result
+ )
+
+ def to_msg(self):
+ msg = [Response.TYPE, self.id, self.error, self.result]
+ return json.dumps(msg)
+
+ @staticmethod
+ def from_msg(data):
+ assert data[0] == Response.TYPE
+ return Response(data[1], data[2], data[3])
+
+
+class SocketContext(object):
+ """Object that guards access to a socket via a lock.
+
+ The socket must be accessed using this object as a context manager;
+ access to the socket outside of a context will bypass the lock."""
+
+ def __init__(self, host, port, timeout):
+ self.lock = RLock()
+
+ self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self._sock.settimeout(timeout)
+ self._sock.connect((host, port))
+
+ @property
+ def socket_timeout(self):
+ return self._sock.gettimeout()
+
+ @socket_timeout.setter
+ def socket_timeout(self, value):
+ self._sock.settimeout(value)
+
+ def __enter__(self):
+ self.lock.acquire()
+ return self._sock
+
+ def __exit__(self, *args, **kwargs):
+ self.lock.release()
+
+
+class TcpTransport(object):
+ """Socket client that communciates with Marionette via TCP.
+
+ It speaks the protocol of the remote debugger in Gecko, in which
+ messages are always preceded by the message length and a colon, e.g.:
+
+ 7:MESSAGE
+
+ On top of this protocol it uses a Marionette message format, that
+ depending on the protocol level offered by the remote server, varies.
+ Supported protocol levels are `min_protocol_level` and above.
+ """
+
+ max_packet_length = 4096
+ min_protocol_level = 3
+
+ def __init__(self, host, port, socket_timeout=60.0):
+ """If `socket_timeout` is `0` or `0.0`, non-blocking socket mode
+ will be used. Setting it to `1` or `None` disables timeouts on
+ socket operations altogether.
+ """
+ self._socket_context = None
+
+ self.host = host
+ self.port = port
+ self._socket_timeout = socket_timeout
+
+ self.protocol = self.min_protocol_level
+ self.application_type = None
+ self.last_id = 0
+ self.expected_response = None
+
+ @property
+ def socket_timeout(self):
+ return self._socket_timeout
+
+ @socket_timeout.setter
+ def socket_timeout(self, value):
+ self._socket_timeout = value
+
+ if self._socket_context is not None:
+ self._socket_context.socket_timeout = value
+
+ def _unmarshal(self, packet):
+ """Convert data from bytes to a Message subtype
+
+ Message format is [type, msg_id, body1, body2], where body1 and body2 depend
+ on the message type.
+
+ :param packet: Bytes received over the wire representing a complete message.
+ """
+ msg = None
+
+ data = json.loads(packet)
+ msg_type = data[0]
+
+ if msg_type == Command.TYPE:
+ msg = Command.from_msg(data)
+ elif msg_type == Response.TYPE:
+ msg = Response.from_msg(data)
+ else:
+ raise ValueError("Invalid message body {!r}".format(packet))
+
+ return msg
+
+ def receive(self, unmarshal=True):
+ """Wait for the next complete response from the remote.
+
+ Packet format is length-prefixed JSON:
+
+ packet = digit+ ":" body
+ digit = "0"-"9"
+ body = JSON text
+
+ :param unmarshal: Default is to deserialise the packet and
+ return a ``Message`` type. Setting this to false will return
+ the raw packet.
+ """
+ # Initally we read 4 bytes. We don't support reading beyond the end of a message, and
+ # so assuming the JSON body has to be an array or object, the minimum possible message
+ # is 4 bytes: "2:{}". In practice the marionette format has some required fields so the
+ # message is longer, but 4 bytes allows reading messages with bodies up to 999 bytes in
+ # length in two reads, which is the common case.
+ with self._socket_context as sock:
+ recv_bytes = 4
+
+ length_prefix = b""
+
+ body_length = -1
+ body_received = 0
+ body_parts = []
+
+ now = time.time()
+ timeout_time = (
+ now + self.socket_timeout if self.socket_timeout is not None else None
+ )
+
+ while recv_bytes > 0:
+ if timeout_time is not None and time.time() > timeout_time:
+ raise socket.timeout(
+ "Connection timed out after {}s".format(self.socket_timeout)
+ )
+
+ try:
+ chunk = sock.recv(recv_bytes)
+ except OSError:
+ continue
+
+ if not chunk:
+ raise socket.error("No data received over socket")
+
+ body_part = None
+ if body_length > 0:
+ body_part = chunk
+ else:
+ parts = chunk.split(b":", 1)
+ length_prefix += parts[0]
+
+ # With > 10 decimal digits we aren't going to have a 32 bit number
+ if len(length_prefix) > 10:
+ raise ValueError(
+ "Invalid message length: {!r}".format(length_prefix)
+ )
+
+ if len(parts) == 2:
+ # We found a : so we know the full length
+ err = None
+ try:
+ body_length = int(length_prefix)
+ except ValueError:
+ err = "expected an integer"
+ else:
+ if body_length <= 0:
+ err = "expected a positive integer"
+ elif body_length > 2 ** 32 - 1:
+ err = "expected a 32 bit integer"
+ if err is not None:
+ raise ValueError(
+ "Invalid message length: {} got {!r}".format(
+ err, length_prefix
+ )
+ )
+ body_part = parts[1]
+
+ # If we didn't find a : yet we keep reading 4 bytes at a time until we do.
+ # We could increase this here to 7 bytes (since we can't have more than 10
+ # length bytes and a seperator byte), or just increase it to
+ # int(length_prefix) + 1 since that's the minimum total number of remaining
+ # bytes (if the : is in the next byte), but it's probably not worth optimising
+ # for large messages.
+
+ if body_part is not None:
+ body_received += len(body_part)
+ body_parts.append(body_part)
+ recv_bytes = body_length - body_received
+
+ body = b"".join(body_parts)
+ if unmarshal:
+ msg = self._unmarshal(body)
+ self.last_id = msg.id
+
+ # keep reading incoming responses until
+ # we receive the user's expected response
+ if isinstance(msg, Response) and msg != self.expected_response:
+ return self.receive(unmarshal)
+
+ return msg
+ return body
+
+ def connect(self):
+ """Connect to the server and process the hello message we expect
+ to receive in response.
+
+ Returns a tuple of the protocol level and the application type.
+ """
+ try:
+ self._socket_context = SocketContext(
+ self.host, self.port, self._socket_timeout
+ )
+ except Exception:
+ # Unset so that the next attempt to send will cause
+ # another connection attempt.
+ self._socket_context = None
+ raise
+
+ try:
+ with SocketTimeout(self._socket_context, 60.0):
+ # first packet is always a JSON Object
+ # which we can use to tell which protocol level we are at
+ raw = self.receive(unmarshal=False)
+ except socket.timeout:
+ exc_cls, exc, tb = sys.exc_info()
+ msg = "Connection attempt failed because no data has been received over the socket: {}"
+ six.reraise(exc_cls, exc_cls(msg.format(exc)), tb)
+
+ hello = json.loads(raw)
+ application_type = hello.get("applicationType")
+ protocol = hello.get("marionetteProtocol")
+
+ if application_type != "gecko":
+ raise ValueError(
+ "Application type '{}' is not supported".format(application_type)
+ )
+
+ if not isinstance(protocol, int) or protocol < self.min_protocol_level:
+ msg = "Earliest supported protocol level is '{}' but got '{}'"
+ raise ValueError(msg.format(self.min_protocol_level, protocol))
+
+ self.application_type = application_type
+ self.protocol = protocol
+
+ return (self.protocol, self.application_type)
+
+ def send(self, obj):
+ """Send message to the remote server. Allowed input is a
+ ``Message`` instance or a JSON serialisable object.
+ """
+ if not self._socket_context:
+ self.connect()
+
+ if isinstance(obj, Message):
+ data = obj.to_msg()
+ if isinstance(obj, Command):
+ self.expected_response = obj
+ else:
+ data = json.dumps(obj)
+ data = six.ensure_binary(data)
+ payload = six.ensure_binary(str(len(data))) + b":" + data
+
+ with self._socket_context as sock:
+ totalsent = 0
+ while totalsent < len(payload):
+ sent = sock.send(payload[totalsent:])
+ if sent == 0:
+ raise IOError(
+ "Socket error after sending {0} of {1} bytes".format(
+ totalsent, len(payload)
+ )
+ )
+ else:
+ totalsent += sent
+
+ def respond(self, obj):
+ """Send a response to a command. This can be an arbitrary JSON
+ serialisable object or an ``Exception``.
+ """
+ res, err = None, None
+ if isinstance(obj, Exception):
+ err = obj
+ else:
+ res = obj
+ msg = Response(self.last_id, err, res)
+ self.send(msg)
+ return self.receive()
+
+ def request(self, name, params):
+ """Sends a message to the remote server and waits for a response
+ to come back.
+ """
+ self.last_id = self.last_id + 1
+ cmd = Command(self.last_id, name, params)
+ self.send(cmd)
+ return self.receive()
+
+ def close(self):
+ """Close the socket.
+
+ First forces the socket to not send data anymore, and then explicitly
+ close it to free up its resources.
+
+ See: https://docs.python.org/2/howto/sockets.html#disconnecting
+ """
+ if self._socket_context:
+ with self._socket_context as sock:
+ try:
+ sock.shutdown(socket.SHUT_RDWR)
+ except IOError as exc:
+ # If the socket is already closed, don't care about:
+ # Errno 57: Socket not connected
+ # Errno 107: Transport endpoint is not connected
+ if exc.errno not in (57, 107):
+ raise
+
+ if sock:
+ # Guard against unclean shutdown.
+ sock.close()
+ self._socket_context = None
+
+ def __del__(self):
+ self.close()
diff --git a/testing/marionette/client/marionette_driver/wait.py b/testing/marionette/client/marionette_driver/wait.py
new file mode 100644
index 0000000000..bc34ccf4cb
--- /dev/null
+++ b/testing/marionette/client/marionette_driver/wait.py
@@ -0,0 +1,175 @@
+# 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 collections
+import sys
+import time
+
+from . import errors
+
+DEFAULT_TIMEOUT = 5
+DEFAULT_INTERVAL = 0.1
+
+
+class Wait(object):
+
+ """An explicit conditional utility class for waiting until a condition
+ evaluates to true or not null.
+
+ This will repeatedly evaluate a condition in anticipation for a
+ truthy return value, or its timeout to expire, or its waiting
+ predicate to become true.
+
+ A `Wait` instance defines the maximum amount of time to wait for a
+ condition, as well as the frequency with which to check the
+ condition. Furthermore, the user may configure the wait to ignore
+ specific types of exceptions whilst waiting, such as
+ `errors.NoSuchElementException` when searching for an element on
+ the page.
+
+ """
+
+ def __init__(
+ self,
+ marionette,
+ timeout=None,
+ interval=None,
+ ignored_exceptions=None,
+ clock=None,
+ ):
+ """Configure the Wait instance to have a custom timeout, interval, and
+ list of ignored exceptions. Optionally a different time
+ implementation than the one provided by the standard library
+ (time) can also be provided.
+
+ Sample usage::
+
+ # Wait 30 seconds for window to open, checking for its presence once
+ # every 5 seconds.
+ wait = Wait(marionette, timeout=30, interval=5,
+ ignored_exceptions=errors.NoSuchWindowException)
+ window = wait.until(lambda m: m.switch_to_window(42))
+
+ :param marionette: The input value to be provided to
+ conditions, usually a Marionette instance.
+
+ :param timeout: How long to wait for the evaluated condition
+ to become true. The default timeout is
+ `wait.DEFAULT_TIMEOUT`.
+
+ :param interval: How often the condition should be evaluated.
+ In reality the interval may be greater as the cost of
+ evaluating the condition function. If that is not the case the
+ interval for the next condition function call is shortend to keep
+ the original interval sequence as best as possible.
+ The default polling interval is `wait.DEFAULT_INTERVAL`.
+
+ :param ignored_exceptions: Ignore specific types of exceptions
+ whilst waiting for the condition. Any exceptions not
+ whitelisted will be allowed to propagate, terminating the
+ wait.
+
+ :param clock: Allows overriding the use of the runtime's
+ default time library. See `wait.SystemClock` for
+ implementation details.
+
+ """
+
+ self.marionette = marionette
+ self.timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
+ self.interval = interval if interval is not None else DEFAULT_INTERVAL
+ self.clock = clock or SystemClock()
+ self.end = self.clock.now + self.timeout
+
+ exceptions = []
+ if ignored_exceptions is not None:
+ if isinstance(ignored_exceptions, collections.abc.Iterable):
+ exceptions.extend(iter(ignored_exceptions))
+ else:
+ exceptions.append(ignored_exceptions)
+ self.exceptions = tuple(set(exceptions))
+
+ def until(self, condition, is_true=None, message=""):
+ """Repeatedly runs condition until its return value evaluates to true,
+ or its timeout expires or the predicate evaluates to true.
+
+ This will poll at the given interval until the given timeout
+ is reached, or the predicate or conditions returns true. A
+ condition that returns null or does not evaluate to true will
+ fully elapse its timeout before raising an
+ `errors.TimeoutException`.
+
+ If an exception is raised in the condition function and it's
+ not ignored, this function will raise immediately. If the
+ exception is ignored, it will continue polling for the
+ condition until it returns successfully or a
+ `TimeoutException` is raised.
+
+ :param condition: A callable function whose return value will
+ be returned by this function if it evaluates to true.
+
+ :param is_true: An optional predicate that will terminate and
+ return when it evaluates to False. It should be a
+ function that will be passed clock and an end time. The
+ default predicate will terminate a wait when the clock
+ elapses the timeout.
+
+ :param message: An optional message to include in the
+ exception's message if this function times out.
+
+ """
+
+ rv = None
+ last_exc = None
+ until = is_true or until_pred
+ start = self.clock.now
+
+ while not until(self.clock, self.end):
+ try:
+ next = self.clock.now + self.interval
+ rv = condition(self.marionette)
+ except (KeyboardInterrupt, SystemExit):
+ raise
+ except self.exceptions:
+ last_exc = sys.exc_info()
+
+ # Re-adjust the interval depending on how long the callback
+ # took to evaluate the condition
+ interval_new = max(next - self.clock.now, 0)
+
+ if not rv:
+ self.clock.sleep(interval_new)
+ continue
+
+ if rv is not None:
+ return rv
+
+ self.clock.sleep(interval_new)
+
+ if message:
+ message = " with message: {}".format(message)
+
+ raise errors.TimeoutException(
+ # pylint: disable=W1633
+ "Timed out after {0:.1f} seconds{1}".format(
+ float(round((self.clock.now - start), 1)), message if message else ""
+ ),
+ cause=last_exc,
+ )
+
+
+def until_pred(clock, end):
+ return clock.now >= end
+
+
+class SystemClock(object):
+ def __init__(self):
+ self._time = time
+
+ def sleep(self, duration):
+ self._time.sleep(duration)
+
+ @property
+ def now(self):
+ return self._time.time()