diff options
Diffstat (limited to 'testing/marionette/client/marionette_driver')
14 files changed, 4149 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..0718925e20 --- /dev/null +++ b/testing/marionette/client/marionette_driver/__init__.py @@ -0,0 +1,24 @@ +# 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 __future__ import absolute_import + +__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..8dab5086be --- /dev/null +++ b/testing/marionette/client/marionette_driver/addons.py @@ -0,0 +1,77 @@ +# 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 __future__ import absolute_import +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..9b0b611ac0 --- /dev/null +++ b/testing/marionette/client/marionette_driver/by.py @@ -0,0 +1,27 @@ +# 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. + +from __future__ import absolute_import + + +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..fd646db6d8 --- /dev/null +++ b/testing/marionette/client/marionette_driver/date_time_value.py @@ -0,0 +1,51 @@ +# 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 __future__ import absolute_import + + +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..223440a8a8 --- /dev/null +++ b/testing/marionette/client/marionette_driver/decorators.py @@ -0,0 +1,82 @@ +# 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 __future__ import absolute_import, print_function + +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..062eb12883 --- /dev/null +++ b/testing/marionette/client/marionette_driver/errors.py @@ -0,0 +1,200 @@ +# 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 __future__ import absolute_import + +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 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 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..1605b2adad --- /dev/null +++ b/testing/marionette/client/marionette_driver/expected.py @@ -0,0 +1,317 @@ +# 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 __future__ import absolute_import + +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..9ff56428e0 --- /dev/null +++ b/testing/marionette/client/marionette_driver/geckoinstance.py @@ -0,0 +1,639 @@ +# 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! +# +# 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. + +from __future__ import absolute_import + +import codecs +import os +import sys +import tempfile +import time +import traceback + +from copy import deepcopy + +import mozversion + +from mozprofile import Profile +from mozrunner import Runner, FennecEmulatorRunner +import six +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, + # 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, + # Disable the ProcessHangMonitor + "dom.ipc.reportProcessHangs": False, + # No slow script dialogs + "dom.max_chrome_script_run_time": 0, + "dom.max_script_run_time": 0, + # DOM Push + "dom.push.connection.enabled": False, + # 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, + # Make sure opening about:addons won"t hit the network + "extensions.getAddons.discovery.api_url": "data:, ", + # Allow the application to have focus even it runs in the background + "focusmanager.testmode": True, + # Disable useragent updates + "general.useragent.updates.enabled": False, + # 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, + # Disable idle-daily notifications to avoid expensive operations + # that may cause unexpected test timeouts. + "idle.lastDailyNotification": -1, + "javascript.options.showInConsole": True, + # (deprecated and can be removed when Firefox 60 ships) + "marionette.defaultPrefs.enabled": True, + # Disable recommended automation prefs in CI + "marionette.prefs.recommended": False, + # 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", + # 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, + # 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": "http://%(server)s/dummy/blocklist/", + # Disable password capture, so that tests that include forms aren"t + # influenced by the presence of the persistent doorhanger notification + "signon.rememberSignons": False, + # Prevent starting into safe mode after application crashes + "toolkit.startup.max_resumed_crashes": -1, + # Enabling the support for File object creation in the content process. + "dom.file.createInChild": True, + } + + 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, + enable_webrender=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 + self.enable_webrender = enable_webrender + + # 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.debugger.remote-enabled": True, + "devtools.chrome.enabled": True, + "devtools.debugger.prompt-connection": False, + "marionette.debugging.clicktostart": 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 six.PY2: + process_args["stream"] = codecs.getwriter("utf-8")(sys.stdout) + else: + process_args["stream"] = codecs.getwriter("utf-8")(sys.stdout.buffer) + else: + process_args["logfile"] = self.gecko_log + + env = os.environ.copy() + + if self.headless: + env["MOZ_HEADLESS"] = "1" + env["DISPLAY"] = "77" # Set a fake display. + + if self.enable_webrender: + env["MOZ_WEBRENDER"] = "1" + env["MOZ_ACCELERATED"] = "1" + else: + env["MOZ_WEBRENDER"] = "0" + + # 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 safebrowsing components + "browser.safebrowsing.blockedURIs.enabled": False, + "browser.safebrowsing.downloads.enabled": False, + "browser.safebrowsing.passwords.enabled": False, + "browser.safebrowsing.malware.enabled": False, + "browser.safebrowsing.phishing.enabled": False, + # Do not restore the last open set of tabs if the browser has crashed + "browser.sessionstore.resume_from_crash": False, + # Disable 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() + if self.enable_webrender: + env["MOZ_WEBRENDER"] = "1" + else: + env["MOZ_WEBRENDER"] = "0" + + 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, + # 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 safebrowsing components + "browser.safebrowsing.blockedURIs.enabled": False, + "browser.safebrowsing.downloads.enabled": False, + "browser.safebrowsing.passwords.enabled": False, + "browser.safebrowsing.malware.enabled": False, + "browser.safebrowsing.phishing.enabled": False, + # 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, + # 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 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, + # Only allow the old modal dialogs. This should be removed when there is + # support for the new modal UI (see Bug 1686741). + "prompts.contentPromptSubDialog": 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..205e9556e2 --- /dev/null +++ b/testing/marionette/client/marionette_driver/keys.py @@ -0,0 +1,90 @@ +# 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. + +from __future__ import absolute_import + + +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..5270ff5ffb --- /dev/null +++ b/testing/marionette/client/marionette_driver/localization.py @@ -0,0 +1,56 @@ +# 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 __future__ import absolute_import + + +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..ce3f67f19d --- /dev/null +++ b/testing/marionette/client/marionette_driver/marionette.py @@ -0,0 +1,1984 @@ +# 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 __future__ import absolute_import, division + +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 +from . import transport +from .decorators import do_process_check +from .geckoinstance import GeckoInstance +from .keys import Keys +from .timeout import Timeouts + +CHROME_ELEMENT_KEY = "chromeelement-9fc5-4b51-a3c8-01716eedeb04" +FRAME_KEY = "frame-075b-4da1-b6ba-e579c2d3230a" +WEB_ELEMENT_KEY = "element-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 = (CHROME_ELEMENT_KEY, 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" + ) + + @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 CHROME_ELEMENT_KEY in json: + return cls(marionette, json[CHROME_ELEMENT_KEY], CHROME_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 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.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 + + 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." + ) + + def __del__(self): + 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._unwrap_response(res.get(key)) + else: + return self._unwrap_response(res) + + def _unwrap_response(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, list): + return list(self._unwrap_response(item) for item in value) + else: + return value + + 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: + if returncode == 0: + 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( + """ + Components.utils.import("resource://gre/modules/Preferences.jsm"); + 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( + """ + Components.utils.import("resource://gre/modules/Preferences.jsm"); + + 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( + """ + Components.utils.import("resource://gre/modules/Preferences.jsm"); + + 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() + + # Restore the context as used before the restart + self.set_context(context) + + def _request_in_app_shutdown(self, *shutdown_flags): + """Attempt to quit the currently running instance from inside the + application. + + Duplicate entries in `shutdown_flags` are removed, and + `"eForceQuit"` is added if no other `*Quit` flags are given. + This provides backwards compatible behaviour with earlier + Firefoxen. + + This method effectively calls `Services.startup.quit` in Gecko. + Possible flag values are listed at http://mzl.la/1X0JZsC. + + :param shutdown_flags: Optional additional quit masks to include. + Duplicates are removed, and `"eForceQuit"` is added if no + flags ending with `"Quit"` are present. + + :throws InvalidArgumentException: If there are multiple + `shutdown_flags` ending with `"Quit"`. + + :returns: The cause of shutdown. + """ + + # The vast majority of this function was implemented inside + # the quit command as part of bug 1337743, and can be + # removed from here in Firefox 55 at the earliest. + + # remove duplicates + flags = set(shutdown_flags) + + # add eForceQuit if there are no *Quits + if not any(flag.endswith("Quit") for flag in flags): + flags = flags | set(("eForceQuit",)) + + # Trigger a quit-application-requested observer notification + # so that components can safely shutdown before quitting the + # application. + with self.using_context("chrome"): + canceled = self.execute_script( + """ + Components.utils.import("resource://gre/modules/Services.jsm"); + let cancelQuit = Components.classes["@mozilla.org/supports-PRBool;1"] + .createInstance(Components.interfaces.nsISupportsPRBool); + Services.obs.notifyObservers(cancelQuit, "quit-application-requested", null); + return cancelQuit.data; + """ + ) + if canceled: + raise errors.MarionetteException( + "Something cancelled the quit application request" + ) + + body = None + if len(flags) > 0: + body = {"flags": list(flags)} + + return self._send_message("Marionette:Quit", body, key="cause") + + @do_process_check + def quit(self, clean=False, in_app=False, callback=None): + """Terminate the currently running instance. + + 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 False the same profile will be used after the next start of + the application. Note that the in app initiated restart always + maintains the same profile. + :param in_app: If True, marionette will cause a quit from within the + browser. Otherwise the browser will be quit immediately + by killing the process. + :param callback: If provided and `in_app` is True, the callback will + be used to trigger the shutdown. + """ + if not self.instance: + raise errors.MarionetteException( + "quit() can only be called " "on Gecko instances launched by Marionette" + ) + + cause = None + if in_app: + 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() + else: + cause = 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 + + returncode = self.instance.runner.wait(timeout=self.shutdown_timeout) + if returncode is None: + # The process did not shutdown itself, so force-closing it. + self.cleanup() + + message = "Process still running {}s after quit request" + raise IOError(message.format(self.shutdown_timeout)) + + self.is_shutting_down = False + self.delete_session(send_request=False) + + else: + self.delete_session(send_request=False) + self.instance.close(clean=clean) + + if cause not in (None, "shutdown"): + raise errors.MarionetteException( + "Unexpected shutdown reason '{}' for " + "quitting the process.".format(cause) + ) + + @do_process_check + def restart(self, clean=False, in_app=False, callback=None): + """ + This will terminate the currently running instance, and spawn a new instance + with the same profile and then reuse the session id when creating a session again. + + :param clean: If False the same 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 + browser. Otherwise the browser 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 restart. + """ + 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") + + cause = None + 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() + else: + cause = self._request_in_app_shutdown("eRestart") + + 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) + + if cause not in (None, "restart"): + raise errors.MarionetteException( + "Unexpected shutdown reason '{}' for " + "restarting the process".format(cause) + ) + + self.start_session() + # 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) + + 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} + + 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"] + # 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 + """ + 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 + """ + self.chrome_window = self._send_message( + "WebDriver:GetChromeWindowHandle", 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. Called in the + chrome context, it will list all available windows, not just + browser windows (e.g. not just navigator.browser). + + 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 + """ + 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 + """ + return self._send_message("WebDriver:GetChromeWindowHandles") + + @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, CHROME_ELEMENT_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, list): + unwrapped = [] + for item in value: + unwrapped.append(self._from_json(item)) + return unwrapped + elif isinstance(value, dict): + unwrapped = {} + for key in value: + if key in HTMLElement.identifiers: + return HTMLElement._from_json(value[key], self) + else: + unwrapped[key] = self._from_json(value[key]) + return unwrapped + 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 self._from_json(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 self._from_json(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..99a9746082 --- /dev/null +++ b/testing/marionette/client/marionette_driver/timeout.py @@ -0,0 +1,106 @@ +# 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 __future__ import absolute_import + +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..1cdc777d9d --- /dev/null +++ b/testing/marionette/client/marionette_driver/transport.py @@ -0,0 +1,318 @@ +# 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 __future__ import absolute_import + +import json +import socket +import sys +import time + +import six + + +class SocketTimeout(object): + def __init__(self, socket, timeout): + self.sock = socket + self.timeout = timeout + self.old_timeout = None + + def __enter__(self): + self.old_timeout = self.sock.gettimeout() + self.sock.settimeout(self.timeout) + + def __exit__(self, *args, **kwargs): + self.sock.settimeout(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(payload): + data = json.loads(payload) + 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(payload): + data = json.loads(payload) + assert data[0] == Response.TYPE + return Response(data[1], data[2], data[3]) + + +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._sock = 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._sock: + self._sock.settimeout(value) + + def _unmarshal(self, packet): + msg = None + + # protocol 3 and above + if self.protocol >= 3: + if six.PY3: + typ = int(chr(packet[1])) + else: + typ = int(packet[1]) + if typ == Command.TYPE: + msg = Command.from_msg(packet) + elif typ == Response.TYPE: + msg = Response.from_msg(packet) + + return msg + + def receive(self, unmarshal=True): + """Wait for the next complete response from the remote. + + :param unmarshal: Default is to deserialise the packet and + return a ``Message`` type. Setting this to false will return + the raw packet. + """ + now = time.time() + data = b"" + bytes_to_recv = 10 + + while self.socket_timeout is None or (time.time() - now < self.socket_timeout): + try: + chunk = self._sock.recv(bytes_to_recv) + data += chunk + except socket.timeout: + pass + else: + if not chunk: + raise socket.error("No data received over socket") + + sep = data.find(b":") + if sep > -1: + length = data[0:sep] + remaining = data[sep + 1 :] + + if len(remaining) == int(length): + if unmarshal: + msg = self._unmarshal(remaining) + 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 + + else: + return remaining + + bytes_to_recv = int(length) - len(remaining) + + raise socket.timeout( + "Connection timed out after {}s".format(self.socket_timeout) + ) + + 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._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock.settimeout(self.socket_timeout) + + self._sock.connect((self.host, self.port)) + except Exception: + # Unset so that the next attempt to send will cause + # another connection attempt. + self._sock = None + raise + + try: + with SocketTimeout(self._sock, 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._sock: + 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 + + totalsent = 0 + while totalsent < len(payload): + sent = self._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._sock: + try: + self._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 self._sock: + # Guard against unclean shutdown. + self._sock.close() + self._sock = 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..caa9cb1f86 --- /dev/null +++ b/testing/marionette/client/marionette_driver/wait.py @@ -0,0 +1,178 @@ +# 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 __future__ import absolute_import + +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.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() |