diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
commit | 43a97878ce14b72f0981164f87f2e35e14151312 (patch) | |
tree | 620249daf56c0258faa40cbdcf9cfba06de2a846 /testing/marionette/client | |
parent | Initial commit. (diff) | |
download | firefox-43a97878ce14b72f0981164f87f2e35e14151312.tar.xz firefox-43a97878ce14b72f0981164f87f2e35e14151312.zip |
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/marionette/client')
29 files changed, 5555 insertions, 0 deletions
diff --git a/testing/marionette/client/MANIFEST.in b/testing/marionette/client/MANIFEST.in new file mode 100644 index 0000000000..cf628b039c --- /dev/null +++ b/testing/marionette/client/MANIFEST.in @@ -0,0 +1,2 @@ +exclude MANIFEST.in +include requirements.txt diff --git a/testing/marionette/client/docs/Makefile b/testing/marionette/client/docs/Makefile new file mode 100644 index 0000000000..f3d89d6d47 --- /dev/null +++ b/testing/marionette/client/docs/Makefile @@ -0,0 +1,153 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make <target>' where <target> is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + -rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/MarionettePythonClient.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/MarionettePythonClient.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/MarionettePythonClient" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/MarionettePythonClient" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." diff --git a/testing/marionette/client/docs/advanced/actions.rst b/testing/marionette/client/docs/advanced/actions.rst new file mode 100644 index 0000000000..c767bdecdc --- /dev/null +++ b/testing/marionette/client/docs/advanced/actions.rst @@ -0,0 +1,21 @@ +Actions +======= + +.. py:currentmodule:: marionette_driver.marionette + +Action Sequences +---------------- + +:class:`Actions` are designed as a way to simulate user input like a keyboard +or a pointer device as closely as possible. For multiple interactions an +action sequence can be used:: + + element = marionette.find_element("id", "input") + element.click() + + key_chain = self.marionette.actions.sequence("key", "keyboard1") + key_chain.send_keys("fooba").pause(100).key_down("r").perform() + +This will simulate entering "fooba" into the input field, waiting for 100ms, +and pressing the key "r". The pause is optional in this case, but can be useful +for simulating delays typical to a users behaviour. diff --git a/testing/marionette/client/docs/advanced/debug.rst b/testing/marionette/client/docs/advanced/debug.rst new file mode 100644 index 0000000000..895009ef7f --- /dev/null +++ b/testing/marionette/client/docs/advanced/debug.rst @@ -0,0 +1,35 @@ +Debugging +========= + +.. py:currentmodule:: marionette_driver.marionette + +Sometimes when working with Marionette you'll run into unexpected behaviour and +need to do some debugging. This page outlines some of the Marionette methods +that can be useful to you. + +Please note that the best tools for debugging are the `ones that ship with +Gecko`_. This page doesn't describe how to use those with Marionette. Also see +a related topic about `using the debugger with Marionette`_ on MDN. + +.. _ones that ship with Gecko: https://developer.mozilla.org/en-US/docs/Tools +.. _using the debugger with Marionette: https://developer.mozilla.org/en-US/docs/Marionette/Debugging + +Seeing What's on the Page +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Sometimes it's difficult to tell what is actually on the page that is being +manipulated. Either because it happens too fast, the window isn't big enough or +you are manipulating a remote server! There are two methods that can help you +out. The first is :func:`~Marionette.screenshot`:: + + marionette.screenshot() # takes screenshot of entire frame + elem = marionette.find_element(By.ID, 'some-div') + marionette.screenshot(elem) # takes a screenshot of only the given element + +Sometimes you just want to see the DOM layout. You can do this with the +:attr:`~Marionette.page_source` property. Note that the page source depends on +the context you are in:: + + print(marionette.page_source) + marionette.set_context('chrome') + print(marionette.page_source) diff --git a/testing/marionette/client/docs/advanced/findelement.rst b/testing/marionette/client/docs/advanced/findelement.rst new file mode 100644 index 0000000000..6f61fa5e25 --- /dev/null +++ b/testing/marionette/client/docs/advanced/findelement.rst @@ -0,0 +1,87 @@ +Finding Elements +================ +.. py:currentmodule:: marionette_driver.marionette + +One of the most common and yet often most difficult tasks in Marionette is +finding a DOM element on a webpage or in the chrome UI. Marionette provides +several different search strategies to use when finding elements. All search +strategies work with both :func:`~Marionette.find_element` and +:func:`~Marionette.find_elements`, though some strategies are not implemented +in chrome scope. + +In the event that more than one element is matched by the query, +:func:`~Marionette.find_element` will only return the first element found. In +the event that no elements are matched by the query, +:func:`~Marionette.find_element` will raise `NoSuchElementException` while +:func:`~Marionette.find_elements` will return an empty list. + +Search Strategies +----------------- + +Search strategies are defined in the :class:`By` class:: + + from marionette_driver import By + print(By.ID) + +The strategies are: + +* `id` - The easiest way to find an element is to refer to its id directly:: + + container = client.find_element(By.ID, 'container') + +* `class name` - To find elements belonging to a certain class, use `class name`:: + + buttons = client.find_elements(By.CLASS_NAME, 'button') + +* `css selector` - It's also possible to find elements using a `css selector`_:: + + container_buttons = client.find_elements(By.CSS_SELECTOR, '#container .buttons') + +* `name` - Find elements by their name attribute (not implemented in chrome + scope):: + + form = client.find_element(By.NAME, 'signup') + +* `tag name` - To find all the elements with a given tag, use `tag name`:: + + paragraphs = client.find_elements(By.TAG_NAME, 'p') + +* `link text` - A convenience strategy for finding link elements by their + innerHTML (not implemented in chrome scope):: + + link = client.find_element(By.LINK_TEXT, 'Click me!') + +* `partial link text` - Same as `link text` except substrings of the innerHTML + are matched (not implemented in chrome scope):: + + link = client.find_element(By.PARTIAL_LINK_TEXT, 'Clic') + +* `xpath` - Find elements using an xpath_ query:: + + elem = client.find_element(By.XPATH, './/*[@id="foobar"') + +.. _css selector: https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Getting_Started/Selectors +.. _xpath: https://developer.mozilla.org/en-US/docs/Web/XPath + + + +Chaining Searches +----------------- + +In addition to the methods on the Marionette object, HTMLElement objects also +provide :func:`~HTMLElement.find_element` and :func:`~HTMLElement.find_elements` +methods. The difference is that only child nodes of the element will be searched. +Consider the following html snippet:: + + <div id="content"> + <span id="main"></span> + </div> + <div id="footer"></div> + +Doing the following will work:: + + client.find_element(By.ID, 'container').find_element(By.ID, 'main') + +But this will raise a `NoSuchElementException`:: + + client.find_element(By.ID, 'container').find_element(By.ID, 'footer') diff --git a/testing/marionette/client/docs/advanced/landing.rst b/testing/marionette/client/docs/advanced/landing.rst new file mode 100644 index 0000000000..0a44de63d7 --- /dev/null +++ b/testing/marionette/client/docs/advanced/landing.rst @@ -0,0 +1,13 @@ +Advanced Topics +=============== + +Here are a collection of articles explaining some of the more complicated +aspects of Marionette. + +.. toctree:: + :maxdepth: 1 + + findelement + stale + actions + debug diff --git a/testing/marionette/client/docs/advanced/stale.rst b/testing/marionette/client/docs/advanced/stale.rst new file mode 100644 index 0000000000..885083993c --- /dev/null +++ b/testing/marionette/client/docs/advanced/stale.rst @@ -0,0 +1,76 @@ +Dealing with Stale Elements +=========================== +.. py:currentmodule:: marionette_driver.marionette + +Marionette does not keep a live representation of the DOM saved. All it can do +is send commands to the Marionette server which queries the DOM on the client's +behalf. References to elements are also not passed from server to client. A +unique id is generated for each element that gets referenced and a mapping of +id to element object is stored on the server. When commands such as +:func:`~HTMLElement.click` are run, the client sends the element's id along +with the command. The server looks up the proper DOM element in its reference +table and executes the command on it. + +In practice this means that the DOM can change state and Marionette will never +know until it sends another query. For example, look at the following HTML:: + + <head> + <script type=text/javascript> + function addDiv() { + var div = document.createElement("div"); + document.getElementById("container").appendChild(div); + } + </script> + </head> + + <body> + <div id="container"> + </div> + <input id="button" type=button onclick="addDiv();"> + </body> + +Care needs to be taken as the DOM is being modified after the page has loaded. +The following code has a race condition:: + + button = client.find_element('id', 'button') + button.click() + assert len(client.find_elements('css selector', '#container div')) > 0 + + +Explicit Waiting and Expected Conditions +---------------------------------------- +.. py:currentmodule:: marionette_driver + +To avoid the above scenario, manual synchronisation is needed. Waits are used +to pause program execution until a given condition is true. This is a useful +technique to employ when documents load new content or change after +``Document.readyState``'s value changes to "complete". + +The :class:`Wait` helper class provided by Marionette avoids some of the +caveats of ``time.sleep(n)``. It will return immediately once the provided +condition evaluates to true. + +To avoid the race condition in the above example, one could do:: + + from marionette_driver import Wait + + button = client.find_element('id', 'button') + button.click() + + def find_divs(): + return client.find_elements('css selector', '#container div') + + divs = Wait(client).until(find_divs) + assert len(divs) > 0 + +This avoids the race condition. Because finding elements is a common condition +to wait for, it is built in to Marionette. Instead of the above, you could +write:: + + from marionette_driver import Wait + + button = client.find_element('id', 'button') + button.click() + assert len(Wait(client).until(expected.elements_present('css selector', '#container div'))) > 0 + +For a full list of built-in conditions, see :mod:`~marionette_driver.expected`. diff --git a/testing/marionette/client/docs/basics.rst b/testing/marionette/client/docs/basics.rst new file mode 100644 index 0000000000..57f10cd69a --- /dev/null +++ b/testing/marionette/client/docs/basics.rst @@ -0,0 +1,185 @@ +.. py:currentmodule:: marionette_driver.marionette + +Marionette Python Client +======================== + +The Marionette Python client library allows you to remotely control a +Gecko-based browser or device which is running a Marionette_ +server. This includes Firefox Desktop and Firefox for Android. + +The Marionette server is built directly into Gecko and can be started by +passing in a command line option to Gecko, or by using a Marionette-enabled +build. The server listens for connections from various clients. Clients can +then control Gecko by sending commands to the server. + +This is the official Python client for Marionette. There also exists a +`NodeJS client`_ maintained by the Firefox OS automation team. + +.. _Marionette: https://developer.mozilla.org/en-US/docs/Marionette +.. _NodeJS client: https://github.com/mozilla-b2g/gaia/tree/master/tests/jsmarionette + +Getting the Client +------------------ + +The Python client is officially supported. To install it, first make sure you +have `pip installed`_ then run: + +.. parsed-literal:: + pip install marionette_driver + +It's highly recommended to use virtualenv_ when installing Marionette to avoid +package conflicts and other general nastiness. + +You should now be ready to start using Marionette. The best way to learn is to +play around with it. Start a `Marionette-enabled instance of Firefox`_, fire up +a python shell and follow along with the +:doc:`interactive tutorial <interactive>`! + +.. _pip installed: https://pip.pypa.io/en/latest/installing.html +.. _virtualenv: http://virtualenv.readthedocs.org/en/latest/ +.. _Marionette-enabled instance of Firefox: https://developer.mozilla.org/en-US/docs/Mozilla/QA/Marionette/Builds + +Using the Client for Testing +---------------------------- + +Please visit the `Marionette Tests`_ section on MDN for information regarding +testing with Marionette. + +.. _Marionette Tests: https://developer.mozilla.org/en/Marionette/Tests + +Session Management +------------------ +A session is a single instance of a Marionette client connected to a Marionette +server. Before you can start executing commands, you need to start a session +with :func:`start_session() <Marionette.start_session>`: + +.. parsed-literal:: + from marionette_driver.marionette import Marionette + + client = Marionette('127.0.0.1', port=2828) + client.start_session() + +This returns a session id and an object listing the capabilities of the +Marionette server. For example, a server running on Firefox Desktop will +have some features which a server running from Firefox Android won't. +It's also possible to access the capabilities using the +:attr:`~Marionette.session_capabilities` attribute. After finishing with a +session, you can delete it with :func:`~Marionette.delete_session()`. Note that +this will also happen automatically when the Marionette object is garbage +collected. + +Context Management +------------------ +Commands can only be executed in a single window, frame and scope at a time. In +order to run commands elsewhere, it's necessary to explicitly switch to the +appropriate context. + +Use :func:`~Marionette.switch_to_window` to execute commands in the context of a +new window: + +.. parsed-literal:: + original_window = client.current_window_handle + for handle in client.window_handles: + if handle != original_window: + client.switch_to_window(handle) + print("Switched to window with '{}' loaded.".format(client.get_url())) + client.switch_to_window(original_window) + +Similarly, use :func:`~Marionette.switch_to_frame` to execute commands in the +context of a new frame (e.g an <iframe> element): + +.. parsed-literal:: + iframe = client.find_element(By.TAG_NAME, 'iframe') + client.switch_to_frame(iframe) + +Finally Marionette can switch between `chrome` and `content` scope. Chrome is a +privileged scope where you can access things like the Firefox UI itself. +Content scope is where things like webpages live. You can switch between +`chrome` and `content` using the :func:`~Marionette.set_context` and :func:`~Marionette.using_context` functions: + +.. parsed-literal:: + client.set_context(client.CONTEXT_CONTENT) + # content scope + with client.using_context(client.CONTEXT_CHROME): + #chrome scope + ... do stuff ... + # content scope restored + + +Navigation +---------- + +Use :func:`~Marionette.navigate` to open a new website. It's also possible to +move through the back/forward cache using :func:`~Marionette.go_forward` and +:func:`~Marionette.go_back` respectively. To retrieve the currently +open website, use :func:`~Marionette.get_url`: + +.. parsed-literal:: + url = 'http://mozilla.org' + client.navigate(url) + client.go_back() + client.go_forward() + assert client.get_url() == url + + +DOM Elements +------------ + +In order to inspect or manipulate actual DOM elements, they must first be found +using the :func:`~Marionette.find_element` or :func:`~Marionette.find_elements` +methods: + +.. parsed-literal:: + from marionette_driver.marionette import HTMLElement + element = client.find_element(By.ID, 'my-id') + assert type(element) == HTMLElement + elements = client.find_elements(By.TAG_NAME, 'a') + assert type(elements) == list + +For a full list of valid search strategies, see :doc:`advanced/findelement`. + +Now that an element has been found, it's possible to manipulate it: + +.. parsed-literal:: + element.click() + element.send_keys('hello!') + print(element.get_attribute('style')) + +For the full list of possible commands, see the :class:`HTMLElement` +reference. + +Be warned that a reference to an element object can become stale if it was +modified or removed from the document. See :doc:`advanced/stale` for tips +on working around this limitation. + +Script Execution +---------------- + +Sometimes Marionette's provided APIs just aren't enough and it is necessary to +run arbitrary javascript. This is accomplished with the +:func:`~Marionette.execute_script` and :func:`~Marionette.execute_async_script` +functions. They accomplish what their names suggest, the former executes some +synchronous JavaScript, while the latter provides a callback mechanism for +running asynchronous JavaScript: + +.. parsed-literal:: + result = client.execute_script("return arguments[0] + arguments[1];", + script_args=[2, 3]) + assert result == 5 + +The async method works the same way, except it won't return until the +`resolve()` function is called: + +.. parsed-literal:: + result = client.execute_async_script(""" + let [resolve] = arguments; + setTimeout(function() { + resolve("all done"); + }, arguments[0]); + """, script_args=[1000]) + assert result == "all done" + +Beware that running asynchronous scripts can potentially hang the program +indefinitely if they are not written properly. It is generally a good idea to +set a script timeout using :func:`~Marionette.timeout.script` and handling +`ScriptTimeoutException`. diff --git a/testing/marionette/client/docs/conf.py b/testing/marionette/client/docs/conf.py new file mode 100644 index 0000000000..b388f9864c --- /dev/null +++ b/testing/marionette/client/docs/conf.py @@ -0,0 +1,274 @@ +# -*- coding: utf-8 -*- +# +# Marionette Python Client documentation build configuration file, created by +# sphinx-quickstart on Tue Aug 6 13:54:46 2013. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import os +import sys + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# sys.path.insert(0, os.path.abspath('.')) + +here = os.path.dirname(os.path.abspath(__file__)) +parent = os.path.dirname(here) +sys.path.insert(0, parent) + +# -- General configuration ----------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = ["sphinx.ext.autodoc"] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# The suffix of source filenames. +source_suffix = ".rst" + +# The encoding of source files. +# source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = "index" + +# General information about the project. +project = u"Marionette Python Client" +copyright = u"2013, Mozilla Automation and Tools and individual contributors" + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +# version = '0' +# The full version, including alpha/beta/rc tags. +# release = '0' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +# today = '' +# Else, today_fmt is used as the format for a strftime call. +# today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ["_build"] + +# The reST default role (used for this markup: `text`) to use for all documents. +# default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +# add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +# add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +# show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + +# A list of ignored prefixes for module index sorting. +# modindex_common_prefix = [] + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. + +html_theme = "default" + +on_rtd = os.environ.get("READTHEDOCS", None) == "True" + +if not on_rtd: + try: + import sphinx_rtd_theme + + html_theme = "sphinx_rtd_theme" + html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + except ImportError: + pass + + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +# html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# "<project> v<release> documentation". +# html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +# html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +# html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +# html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +# html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +# html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# html_additional_pages = {} + +# If false, no module index is generated. +# html_domain_indices = True + +# If false, no index is generated. +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# html_split_index = False + +# If true, links to the reST sources are added to the pages. +# html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +# html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +html_show_copyright = False + +# If true, an OpenSearch description file will be output, and all pages will +# contain a <link> tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +# html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = "MarionettePythonClientdoc" + + +# -- Options for LaTeX output -------------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # 'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ( + "index", + "MarionettePythonClient.tex", + u"Marionette Python Client Documentation", + u"Mozilla Automation and Tools team", + "manual", + ), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +# latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +# latex_use_parts = False + +# If true, show page references after internal links. +# latex_show_pagerefs = False + +# If true, show URL addresses after external links. +# latex_show_urls = False + +# Documents to append as an appendix to all manuals. +# latex_appendices = [] + +# If false, no module index is generated. +# latex_domain_indices = True + + +# -- Options for manual page output -------------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ( + "index", + "marionettepythonclient", + u"Marionette Python Client Documentation", + [u"Mozilla Automation and Tools team"], + 1, + ) +] + +# If true, show URL addresses after external links. +# man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------------ + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ( + "index", + "MarionettePythonClient", + "Marionette Python Client Documentation", + "Mozilla Automation and Tools team", + "MarionettePythonClient", + "One line description of project.", + "Miscellaneous", + ), +] + +# Documents to append as an appendix to all manuals. +# texinfo_appendices = [] + +# If false, no module index is generated. +# texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +# texinfo_show_urls = 'footnote' diff --git a/testing/marionette/client/docs/index.rst b/testing/marionette/client/docs/index.rst new file mode 100644 index 0000000000..b1f266726c --- /dev/null +++ b/testing/marionette/client/docs/index.rst @@ -0,0 +1,16 @@ +.. include:: basics.rst + +Indices and tables +------------------ + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + +.. toctree:: + :hidden: + + Getting Started <basics> + Interactive Tutorial <interactive> + advanced/landing + reference diff --git a/testing/marionette/client/docs/interactive.rst b/testing/marionette/client/docs/interactive.rst new file mode 100644 index 0000000000..7b2ebe2ec3 --- /dev/null +++ b/testing/marionette/client/docs/interactive.rst @@ -0,0 +1,52 @@ +Using the Client Interactively +============================== + +Once you installed the client and have Marionette running, you can fire +up your favourite interactive python environment and start playing with +Marionette. Let's use a typical python shell: + +.. parsed-literal:: + python + +First, import Marionette: + +.. parsed-literal:: + from marionette_driver.marionette import Marionette + +Now create the client for this session. Assuming you're using the default +port on a Marionette instance running locally: + +.. parsed-literal:: + client = Marionette(host='127.0.0.1', port=2828) + client.start_session() + +This will return some id representing your session id. Now that you've +established a connection, let's start doing interesting things: + +.. parsed-literal:: + client.navigate("http://www.mozilla.org") + +Now you're at mozilla.org! You can even verify it using the following: + +.. parsed-literal:: + client.get_url() + +You can execute Javascript code in the scope of the web page: + +.. parsed-literal:: + client.execute_script("return window.document.title;") + +This will you return the title of the web page as set in the head section +of the HTML document. + +Also you can find elements and click on those. Let's say you want to get +the first link: + +.. parsed-literal:: + from marionette_driver import By + first_link = client.find_element(By.TAG_NAME, "a") + +first_link now holds a reference to the first link on the page. You can click it: + +.. parsed-literal:: + first_link.click() diff --git a/testing/marionette/client/docs/make.bat b/testing/marionette/client/docs/make.bat new file mode 100644 index 0000000000..fb02fc1a8c --- /dev/null +++ b/testing/marionette/client/docs/make.bat @@ -0,0 +1,190 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^<target^>` where ^<target^> is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\MarionettePythonClient.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\MarionettePythonClient.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +:end diff --git a/testing/marionette/client/docs/reference.rst b/testing/marionette/client/docs/reference.rst new file mode 100644 index 0000000000..2765e8b1a4 --- /dev/null +++ b/testing/marionette/client/docs/reference.rst @@ -0,0 +1,66 @@ +============= +API Reference +============= + +Marionette +---------- +.. py:currentmodule:: marionette_driver.marionette.Marionette +.. autoclass:: marionette_driver.marionette.Marionette + :members: + +HTMLElement +----------- +.. py:currentmodule:: marionette_driver.marionette.HTMLElement +.. autoclass:: marionette_driver.marionette.HTMLElement + :members: + +DateTimeValue +------------- +.. py:currentmodule:: marionette_driver.DateTimeValue +.. autoclass:: marionette_driver.DateTimeValue + :members: + +Actions +------- +.. py:currentmodule:: marionette_driver.marionette.Actions +.. autoclass:: marionette_driver.marionette.Actions + :members: + +Alert +----- +.. py:currentmodule:: marionette_driver.marionette.Alert +.. autoclass:: marionette_driver.marionette.Alert + :members: + +Wait +---- +.. py:currentmodule:: marionette_driver.Wait +.. autoclass:: marionette_driver.Wait + :members: + :special-members: +.. autoattribute marionette_driver.wait.DEFAULT_TIMEOUT +.. autoattribute marionette_driver.wait.DEFAULT_INTERVAL + +Built-in Conditions +^^^^^^^^^^^^^^^^^^^ +.. py:currentmodule:: marionette_driver.expected +.. automodule:: marionette_driver.expected + :members: + +Timeouts +-------- +.. py:currentmodule:: marionette_driver.timeout.Timeouts +.. autoclass:: marionette_driver.timeout.Timeouts + :members: + +Addons +------ +.. py:currentmodule:: marionette_driver.addons.Addons +.. autoclass:: marionette_driver.addons.Addons + :members: + +Localization +------------ +.. py:currentmodule:: marionette_driver.localization.L10n +.. autoclass:: marionette_driver.localization.L10n + :members: diff --git a/testing/marionette/client/marionette_driver/__init__.py b/testing/marionette/client/marionette_driver/__init__.py new file mode 100644 index 0000000000..53c18ae0e5 --- /dev/null +++ b/testing/marionette/client/marionette_driver/__init__.py @@ -0,0 +1,22 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +__version__ = "3.1.0" + +from marionette_driver import ( + addons, + by, + date_time_value, + decorators, + errors, + expected, + geckoinstance, + keys, + localization, + marionette, + wait, +) +from marionette_driver.by import By +from marionette_driver.date_time_value import DateTimeValue +from marionette_driver.wait import Wait diff --git a/testing/marionette/client/marionette_driver/addons.py b/testing/marionette/client/marionette_driver/addons.py new file mode 100644 index 0000000000..09f44e3e54 --- /dev/null +++ b/testing/marionette/client/marionette_driver/addons.py @@ -0,0 +1,76 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import os + +from . import errors + +__all__ = ["Addons", "AddonInstallException"] + + +class AddonInstallException(errors.MarionetteException): + pass + + +class Addons(object): + """An API for installing and inspecting addons during Gecko + runtime. This is a partially implemented wrapper around Gecko's + `AddonManager API`_. + + For example:: + + from marionette_driver.addons import Addons + addons = Addons(marionette) + addons.install("/path/to/extension.xpi") + + .. _AddonManager API: https://developer.mozilla.org/en-US/Add-ons/Add-on_Manager + + """ + + def __init__(self, marionette): + self._mn = marionette + + def install(self, path, temp=False): + """Install a Firefox addon. + + If the addon is restartless, it can be used right away. Otherwise + a restart using :func:`~marionette_driver.marionette.Marionette.restart` + will be needed. + + :param path: A file path to the extension to be installed. + :param temp: Install a temporary addon. Temporary addons will + automatically be uninstalled on shutdown and do not need + to be signed, though they must be restartless. + + :returns: The addon ID string of the newly installed addon. + + :raises: :exc:`AddonInstallException` + + """ + # On windows we can end up with a path with mixed \ and / + # which Firefox doesn't like + path = path.replace("/", os.path.sep) + + body = {"path": path, "temporary": temp} + try: + return self._mn._send_message("Addon:Install", body, key="value") + except errors.UnknownException as e: + raise AddonInstallException(e) + + def uninstall(self, addon_id): + """Uninstall a Firefox addon. + + If the addon is restartless, it will be uninstalled right away. + Otherwise a restart using :func:`~marionette_driver.marionette.Marionette.restart` + will be needed. + + If the call to uninstall is resulting in a `ScriptTimeoutException`, + an invalid ID is likely being passed in. Unfortunately due to + AddonManager's implementation, it's hard to retrieve this error from + Python. + + :param addon_id: The addon ID string to uninstall. + + """ + self._mn._send_message("Addon:Uninstall", {"id": addon_id}) diff --git a/testing/marionette/client/marionette_driver/by.py b/testing/marionette/client/marionette_driver/by.py new file mode 100644 index 0000000000..b54ca729f2 --- /dev/null +++ b/testing/marionette/client/marionette_driver/by.py @@ -0,0 +1,25 @@ +# Copyright 2008-2009 WebDriver committers +# Copyright 2008-2009 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class By(object): + ID = "id" + XPATH = "xpath" + LINK_TEXT = "link text" + PARTIAL_LINK_TEXT = "partial link text" + NAME = "name" + TAG_NAME = "tag name" + CLASS_NAME = "class name" + CSS_SELECTOR = "css selector" diff --git a/testing/marionette/client/marionette_driver/date_time_value.py b/testing/marionette/client/marionette_driver/date_time_value.py new file mode 100644 index 0000000000..c6f2ed989a --- /dev/null +++ b/testing/marionette/client/marionette_driver/date_time_value.py @@ -0,0 +1,49 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + + +class DateTimeValue(object): + """ + Interface for setting the value of HTML5 "date" and "time" input elements. + + Simple usage example: + + :: + + element = marionette.find_element(By.ID, "date-test") + dt_value = DateTimeValue(element) + dt_value.date = datetime(1998, 6, 2) + + """ + + def __init__(self, element): + self.element = element + + @property + def date(self): + """ + Retrieve the element's string value + """ + return self.element.get_attribute("value") + + # As per the W3C "date" element specification + # (http://dev.w3.org/html5/markup/input.date.html), this value is formatted + # according to RFC 3339: http://tools.ietf.org/html/rfc3339#section-5.6 + @date.setter + def date(self, date_value): + self.element.send_keys(date_value.strftime("%Y-%m-%d")) + + @property + def time(self): + """ + Retrieve the element's string value + """ + return self.element.get_attribute("value") + + # As per the W3C "time" element specification + # (http://dev.w3.org/html5/markup/input.time.html), this value is formatted + # according to RFC 3339: http://tools.ietf.org/html/rfc3339#section-5.6 + @time.setter + def time(self, time_value): + self.element.send_keys(time_value.strftime("%H:%M:%S")) diff --git a/testing/marionette/client/marionette_driver/decorators.py b/testing/marionette/client/marionette_driver/decorators.py new file mode 100644 index 0000000000..95a5c5bbee --- /dev/null +++ b/testing/marionette/client/marionette_driver/decorators.py @@ -0,0 +1,79 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import socket +from functools import wraps + + +def _find_marionette_in_args(*args, **kwargs): + try: + m = [a for a in args + tuple(kwargs.values()) if hasattr(a, "session")][0] + except IndexError: + print("Can only apply decorator to function using a marionette object") + raise + return m + + +def do_process_check(func): + """Decorator which checks the process status after the function has run.""" + + @wraps(func) + def _(*args, **kwargs): + try: + return func(*args, **kwargs) + except (socket.error, socket.timeout): + m = _find_marionette_in_args(*args, **kwargs) + + # In case of socket failures which will also include crashes of the + # application, make sure to handle those correctly. In case of an + # active shutdown just let it bubble up. + if m.is_shutting_down: + raise + + m._handle_socket_failure() + + return _ + + +def uses_marionette(func): + """Decorator which creates a marionette session and deletes it + afterwards if one doesn't already exist. + """ + + @wraps(func) + def _(*args, **kwargs): + m = _find_marionette_in_args(*args, **kwargs) + delete_session = False + if not m.session: + delete_session = True + m.start_session() + + m.set_context(m.CONTEXT_CHROME) + ret = func(*args, **kwargs) + + if delete_session: + m.delete_session() + + return ret + + return _ + + +def using_context(context): + """Decorator which allows a function to execute in certain scope + using marionette.using_context functionality and returns to old + scope once the function exits. + :param context: Either 'chrome' or 'content'. + """ + + def wrap(func): + @wraps(func) + def inner(*args, **kwargs): + m = _find_marionette_in_args(*args, **kwargs) + with m.using_context(context): + return func(*args, **kwargs) + + return inner + + return wrap diff --git a/testing/marionette/client/marionette_driver/errors.py b/testing/marionette/client/marionette_driver/errors.py new file mode 100644 index 0000000000..09dc9f24ef --- /dev/null +++ b/testing/marionette/client/marionette_driver/errors.py @@ -0,0 +1,206 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import traceback + +import six + + +@six.python_2_unicode_compatible +class MarionetteException(Exception): + + """Raised when a generic non-recoverable exception has occured.""" + + status = "webdriver error" + + def __init__(self, message=None, cause=None, stacktrace=None): + """Construct new MarionetteException instance. + + :param message: An optional exception message. + + :param cause: An optional tuple of three values giving + information about the root exception cause. Expected + tuple values are (type, value, traceback). + + :param stacktrace: Optional string containing a stacktrace + (typically from a failed JavaScript execution) that will + be displayed in the exception's string representation. + + """ + self.cause = cause + self.stacktrace = stacktrace + self._message = six.text_type(message) + + def __str__(self): + # pylint: disable=W1645 + msg = self.message + tb = None + + if self.cause: + if type(self.cause) is tuple: + msg += u", caused by {0!r}".format(self.cause[0]) + tb = self.cause[2] + else: + msg += u", caused by {}".format(self.cause) + + if self.stacktrace: + st = u"".join(["\t{}\n".format(x) for x in self.stacktrace.splitlines()]) + msg += u"\nstacktrace:\n{}".format(st) + + if tb: + msg += u": " + u"".join(traceback.format_tb(tb)) + + return six.text_type(msg) + + @property + def message(self): + return self._message + + +class DetachedShadowRootException(MarionetteException): + status = "detached shadow root" + + +class ElementNotSelectableException(MarionetteException): + status = "element not selectable" + + +class ElementClickInterceptedException(MarionetteException): + status = "element click intercepted" + + +class InsecureCertificateException(MarionetteException): + status = "insecure certificate" + + +class InvalidArgumentException(MarionetteException): + status = "invalid argument" + + +class InvalidSessionIdException(MarionetteException): + status = "invalid session id" + + +class TimeoutException(MarionetteException): + status = "timeout" + + +class JavascriptException(MarionetteException): + status = "javascript error" + + +class NoSuchElementException(MarionetteException): + status = "no such element" + + +class NoSuchShadowRootException(MarionetteException): + status = "no such shadow root" + + +class NoSuchWindowException(MarionetteException): + status = "no such window" + + +class StaleElementException(MarionetteException): + status = "stale element reference" + + +class ScriptTimeoutException(MarionetteException): + status = "script timeout" + + +class ElementNotVisibleException(MarionetteException): + """Deprecated. Will be removed with the release of Firefox 54.""" + + status = "element not visible" + + def __init__( + self, + message="Element is not currently visible and may not be manipulated", + stacktrace=None, + cause=None, + ): + super(ElementNotVisibleException, self).__init__( + message, cause=cause, stacktrace=stacktrace + ) + + +class ElementNotAccessibleException(MarionetteException): + status = "element not accessible" + + +class ElementNotInteractableException(MarionetteException): + status = "element not interactable" + + +class NoSuchFrameException(MarionetteException): + status = "no such frame" + + +class InvalidElementStateException(MarionetteException): + status = "invalid element state" + + +class NoAlertPresentException(MarionetteException): + status = "no such alert" + + +class InvalidCookieDomainException(MarionetteException): + status = "invalid cookie domain" + + +class UnableToSetCookieException(MarionetteException): + status = "unable to set cookie" + + +class InvalidElementCoordinates(MarionetteException): + status = "invalid element coordinates" + + +class InvalidSelectorException(MarionetteException): + status = "invalid selector" + + +class MoveTargetOutOfBoundsException(MarionetteException): + status = "move target out of bounds" + + +class SessionNotCreatedException(MarionetteException): + status = "session not created" + + +class UnexpectedAlertOpen(MarionetteException): + status = "unexpected alert open" + + +class UnknownCommandException(MarionetteException): + status = "unknown command" + + +class UnknownException(MarionetteException): + status = "unknown error" + + +class UnsupportedOperationException(MarionetteException): + status = "unsupported operation" + + +class UnresponsiveInstanceException(Exception): + pass + + +es_ = [ + e + for e in locals().values() + if type(e) == type and issubclass(e, MarionetteException) +] +by_string = {e.status: e for e in es_} + + +def lookup(identifier): + """Finds error exception class by associated Selenium JSON wire + protocol number code, or W3C WebDriver protocol string. + + """ + return by_string.get(identifier, MarionetteException) diff --git a/testing/marionette/client/marionette_driver/expected.py b/testing/marionette/client/marionette_driver/expected.py new file mode 100644 index 0000000000..6b272663ad --- /dev/null +++ b/testing/marionette/client/marionette_driver/expected.py @@ -0,0 +1,315 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import types + +from . import errors +from .marionette import HTMLElement + +"""This file provides a set of expected conditions for common use +cases when writing Marionette tests. + +The conditions rely on explicit waits that retries conditions a number +of times until they are either successfully met, or they time out. + +""" + + +class element_present(object): + """Checks that a web element is present in the DOM of the current + context. This does not necessarily mean that the element is + visible. + + You can select which element to be checked for presence by + supplying a locator:: + + el = Wait(marionette).until(expected.element_present(By.ID, "foo")) + + Or by using a function/lambda returning an element:: + + el = Wait(marionette).until( + expected.element_present(lambda m: m.find_element(By.ID, "foo"))) + + :param args: locator or function returning web element + :returns: the web element once it is located, or False + + """ + + def __init__(self, *args): + if len(args) == 1 and isinstance(args[0], types.FunctionType): + self.locator = args[0] + else: + self.locator = lambda m: m.find_element(*args) + + def __call__(self, marionette): + return _find(marionette, self.locator) + + +class element_not_present(element_present): + """Checks that a web element is not present in the DOM of the current + context. + + You can select which element to be checked for lack of presence by + supplying a locator:: + + r = Wait(marionette).until(expected.element_not_present(By.ID, "foo")) + + Or by using a function/lambda returning an element:: + + r = Wait(marionette).until( + expected.element_present(lambda m: m.find_element(By.ID, "foo"))) + + :param args: locator or function returning web element + :returns: True if element is not present, or False if it is present + + """ + + def __init__(self, *args): + super(element_not_present, self).__init__(*args) + + def __call__(self, marionette): + return not super(element_not_present, self).__call__(marionette) + + +class element_stale(object): + """Check that the given element is no longer attached to DOM of the + current context. + + This can be useful for waiting until an element is no longer + present. + + Sample usage:: + + el = marionette.find_element(By.ID, "foo") + # ... + Wait(marionette).until(expected.element_stale(el)) + + :param element: the element to wait for + :returns: False if the element is still attached to the DOM, True + otherwise + + """ + + def __init__(self, element): + self.el = element + + def __call__(self, marionette): + try: + # Calling any method forces a staleness check + self.el.is_enabled() + return False + except (errors.StaleElementException, errors.NoSuchElementException): + # StaleElementException is raised when the element is gone, and + # NoSuchElementException is raised after a process swap. + return True + + +class elements_present(object): + """Checks that web elements are present in the DOM of the current + context. This does not necessarily mean that the elements are + visible. + + You can select which elements to be checked for presence by + supplying a locator:: + + els = Wait(marionette).until(expected.elements_present(By.TAG_NAME, "a")) + + Or by using a function/lambda returning a list of elements:: + + els = Wait(marionette).until( + expected.elements_present(lambda m: m.find_elements(By.TAG_NAME, "a"))) + + :param args: locator or function returning a list of web elements + :returns: list of web elements once they are located, or False + + """ + + def __init__(self, *args): + if len(args) == 1 and isinstance(args[0], types.FunctionType): + self.locator = args[0] + else: + self.locator = lambda m: m.find_elements(*args) + + def __call__(self, marionette): + return _find(marionette, self.locator) + + +class elements_not_present(elements_present): + """Checks that web elements are not present in the DOM of the + current context. + + You can select which elements to be checked for not being present + by supplying a locator:: + + r = Wait(marionette).until(expected.elements_not_present(By.TAG_NAME, "a")) + + Or by using a function/lambda returning a list of elements:: + + r = Wait(marionette).until( + expected.elements_not_present(lambda m: m.find_elements(By.TAG_NAME, "a"))) + + :param args: locator or function returning a list of web elements + :returns: True if elements are missing, False if one or more are + present + + """ + + def __init__(self, *args): + super(elements_not_present, self).__init__(*args) + + def __call__(self, marionette): + return not super(elements_not_present, self).__call__(marionette) + + +class element_displayed(object): + """An expectation for checking that an element is visible. + + Visibility means that the element is not only displayed, but also + has a height and width that is greater than 0 pixels. + + Stale elements, meaning elements that have been detached from the + DOM of the current context are treated as not being displayed, + meaning this expectation is not analogous to the behaviour of + calling :func:`~marionette_driver.marionette.HTMLElement.is_displayed` + on an :class:`~marionette_driver.marionette.HTMLElement`. + + You can select which element to be checked for visibility by + supplying a locator:: + + displayed = Wait(marionette).until(expected.element_displayed(By.ID, "foo")) + + Or by supplying an element:: + + el = marionette.find_element(By.ID, "foo") + displayed = Wait(marionette).until(expected.element_displayed(el)) + + :param args: locator or web element + :returns: True if element is displayed, False if hidden + + """ + + def __init__(self, *args): + self.el = None + if len(args) == 1 and isinstance(args[0], HTMLElement): + self.el = args[0] + else: + self.locator = lambda m: m.find_element(*args) + + def __call__(self, marionette): + if self.el is None: + self.el = _find(marionette, self.locator) + if not self.el: + return False + try: + return self.el.is_displayed() + except errors.StaleElementException: + return False + + +class element_not_displayed(element_displayed): + """An expectation for checking that an element is not visible. + + Visibility means that the element is not only displayed, but also + has a height and width that is greater than 0 pixels. + + Stale elements, meaning elements that have been detached fom the + DOM of the current context are treated as not being displayed, + meaning this expectation is not analogous to the behaviour of + calling :func:`~marionette_driver.marionette.HTMLElement.is_displayed` + on an :class:`~marionette_driver.marionette.HTMLElement`. + + You can select which element to be checked for visibility by + supplying a locator:: + + hidden = Wait(marionette).until(expected.element_not_displayed(By.ID, "foo")) + + Or by supplying an element:: + + el = marionette.find_element(By.ID, "foo") + hidden = Wait(marionette).until(expected.element_not_displayed(el)) + + :param args: locator or web element + :returns: True if element is hidden, False if displayed + + """ + + def __init__(self, *args): + super(element_not_displayed, self).__init__(*args) + + def __call__(self, marionette): + return not super(element_not_displayed, self).__call__(marionette) + + +class element_selected(object): + """An expectation for checking that the given element is selected. + + :param element: the element to be selected + :returns: True if element is selected, False otherwise + + """ + + def __init__(self, element): + self.el = element + + def __call__(self, marionette): + return self.el.is_selected() + + +class element_not_selected(element_selected): + """An expectation for checking that the given element is not + selected. + + :param element: the element to not be selected + :returns: True if element is not selected, False if selected + + """ + + def __init__(self, element): + super(element_not_selected, self).__init__(element) + + def __call__(self, marionette): + return not super(element_not_selected, self).__call__(marionette) + + +class element_enabled(object): + """An expectation for checking that the given element is enabled. + + :param element: the element to check if enabled + :returns: True if element is enabled, False otherwise + + """ + + def __init__(self, element): + self.el = element + + def __call__(self, marionette): + return self.el.is_enabled() + + +class element_not_enabled(element_enabled): + """An expectation for checking that the given element is disabled. + + :param element: the element to check if disabled + :returns: True if element is disabled, False if enabled + + """ + + def __init__(self, element): + super(element_not_enabled, self).__init__(element) + + def __call__(self, marionette): + return not super(element_not_enabled, self).__call__(marionette) + + +def _find(marionette, func): + el = None + + try: + el = func(marionette) + except errors.NoSuchElementException: + pass + + if el is None: + return False + return el diff --git a/testing/marionette/client/marionette_driver/geckoinstance.py b/testing/marionette/client/marionette_driver/geckoinstance.py new file mode 100644 index 0000000000..24c1392495 --- /dev/null +++ b/testing/marionette/client/marionette_driver/geckoinstance.py @@ -0,0 +1,666 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/ + +# ALL CHANGES TO THIS FILE MUST HAVE REVIEW FROM A MARIONETTE PEER! +# +# Please refer to INSTRUCTIONS TO ADD A NEW PREFERENCE in +# remote/shared/RecommendedPreferences.sys.mjs +# +# The Marionette Python client is used out-of-tree with various builds of +# Firefox. Removing a preference from this file will cause regressions, +# so please be careful and get review from a Testing :: Marionette peer +# before you make any changes to this file. + +import codecs +import json +import os +import sys +import tempfile +import time +import traceback +from copy import deepcopy + +import mozversion +import six +from mozprofile import Profile +from mozrunner import FennecEmulatorRunner, Runner +from six import reraise + +from . import errors + + +class GeckoInstance(object): + required_prefs = { + # Make sure Shield doesn't hit the network. + "app.normandy.api_url": "", + # Increase the APZ content response timeout in tests to 1 minute. + # This is to accommodate the fact that test environments tends to be slower + # than production environments (with the b2g emulator being the slowest of them + # all), resulting in the production timeout value sometimes being exceeded + # and causing false-positive test failures. See bug 1176798, bug 1177018, + # bug 1210465. + "apz.content_response_timeout": 60000, + # Disable geolocation ping (#1) + "browser.region.network.url": "", + # Don't pull Top Sites content from the network + "browser.topsites.contile.enabled": False, + # Disable UI tour + "browser.uitour.pinnedTabUrl": "http://%(server)s/uitour-dummy/pinnedTab", + "browser.uitour.url": "http://%(server)s/uitour-dummy/tour", + # Disable captive portal + "captivedetect.canonicalURL": "", + # Defensively disable data reporting systems + "datareporting.healthreport.documentServerURI": "http://%(server)s/dummy/healthreport/", + "datareporting.healthreport.logging.consoleEnabled": False, + "datareporting.healthreport.service.enabled": False, + "datareporting.healthreport.service.firstRun": False, + "datareporting.healthreport.uploadEnabled": False, + # Do not show datareporting policy notifications which can interfere with tests + "datareporting.policy.dataSubmissionEnabled": False, + "datareporting.policy.dataSubmissionPolicyBypassNotification": True, + # Automatically unload beforeunload alerts + "dom.disable_beforeunload": True, + # Enabling the support for File object creation in the content process. + "dom.file.createInChild": True, + # Disable the ProcessHangMonitor + "dom.ipc.reportProcessHangs": False, + # No slow script dialogs + "dom.max_chrome_script_run_time": 0, + "dom.max_script_run_time": 0, + # Disable location change rate limitation + "dom.navigation.locationChangeRateLimit.count": 0, + # DOM Push + "dom.push.connection.enabled": False, + # Screen Orientation API + "dom.screenorientation.allow-lock": True, + # Disable dialog abuse if alerts are triggered too quickly + "dom.successive_dialog_time_limit": 0, + # Only load extensions from the application and user profile + # AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION + "extensions.autoDisableScopes": 0, + "extensions.enabledScopes": 5, + # Disable metadata caching for installed add-ons by default + "extensions.getAddons.cache.enabled": False, + # Disable intalling any distribution add-ons + "extensions.installDistroAddons": False, + # Turn off extension updates so they don't bother tests + "extensions.update.enabled": False, + "extensions.update.notifyUser": False, + # Redirect various extension update URLs + "extensions.blocklist.detailsURL": ( + "http://%(server)s/extensions-dummy/blocklistDetailsURL" + ), + "extensions.blocklist.itemURL": "http://%(server)s/extensions-dummy/blocklistItemURL", + "extensions.hotfix.url": "http://%(server)s/extensions-dummy/hotfixURL", + "extensions.systemAddon.update.url": "http://%(server)s/dummy-system-addons.xml", + "extensions.update.background.url": ( + "http://%(server)s/extensions-dummy/updateBackgroundURL" + ), + "extensions.update.url": "http://%(server)s/extensions-dummy/updateURL", + # Make sure opening about:addons won"t hit the network + "extensions.getAddons.discovery.api_url": "data:, ", + "extensions.getAddons.get.url": "http://%(server)s/extensions-dummy/repositoryGetURL", + "extensions.getAddons.search.browseURL": ( + "http://%(server)s/extensions-dummy/repositoryBrowseURL" + ), + # Allow the application to have focus even it runs in the background + "focusmanager.testmode": True, + # Disable useragent updates + "general.useragent.updates.enabled": False, + # Disable geolocation ping (#2) + "geo.provider.network.url": "", + # Always use network provider for geolocation tests + # so we bypass the OSX dialog raised by the corelocation provider + "geo.provider.testing": True, + # Do not scan Wifi + "geo.wifi.scan": False, + # Ensure webrender is on, no need for environment variables + "gfx.webrender.all": True, + # Disable idle-daily notifications to avoid expensive operations + # that may cause unexpected test timeouts. + "idle.lastDailyNotification": -1, + # Disable Firefox accounts ping + "identity.fxaccounts.auth.uri": "https://{server}/dummy/fxa", + # Disable download and usage of OpenH264, and Widevine plugins + "media.gmp-manager.updateEnabled": False, + # Disable the GFX sanity window + "media.sanity-test.disabled": True, + "media.volume_scale": "0.01", + # Disable connectivity service pings + "network.connectivity-service.enabled": False, + # Do not prompt for temporary redirects + "network.http.prompt-temp-redirect": False, + # Do not automatically switch between offline and online + "network.manage-offline-status": False, + # Make sure SNTP requests don't hit the network + "network.sntp.pools": "%(server)s", + # Privacy and Tracking Protection + "privacy.trackingprotection.enabled": False, + # Disable recommended automation prefs in CI + "remote.prefs.recommended": False, + # Don't do network connections for mitm priming + "security.certerrors.mitm.priming.enabled": False, + # Tests don't wait for the notification button security delay + "security.notification_enable_delay": 0, + # Ensure blocklist updates don't hit the network + "services.settings.server": "data:,#remote-settings-dummy/v1", + # Disable password capture, so that tests that include forms aren"t + # influenced by the presence of the persistent doorhanger notification + "signon.rememberSignons": False, + # Prevent starting into safe mode after application crashes + "toolkit.startup.max_resumed_crashes": -1, + # Disable most telemetry pings + "toolkit.telemetry.server": "https://%(server)s/telemetry-dummy/", + # Disable window occlusion on Windows, see Bug 1802473. + "widget.windows.window_occlusion_tracking.enabled": False, + } + + def __init__( + self, + host=None, + port=None, + bin=None, + profile=None, + addons=None, + app_args=None, + symbols_path=None, + gecko_log=None, + prefs=None, + workspace=None, + verbose=0, + headless=False, + ): + self.runner_class = Runner + self.app_args = app_args or [] + self.runner = None + self.symbols_path = symbols_path + self.binary = bin + + self.marionette_host = host + self.marionette_port = port + self.addons = addons + self.prefs = prefs + self.required_prefs = deepcopy(self.required_prefs) + if prefs: + self.required_prefs.update(prefs) + + self._gecko_log_option = gecko_log + self._gecko_log = None + self.verbose = verbose + self.headless = headless + + # keep track of errors to decide whether instance is unresponsive + self.unresponsive_count = 0 + + # Alternative to default temporary directory + self.workspace = workspace + + # Don't use the 'profile' property here, because sub-classes could add + # further preferences and data, which would not be included in the new + # profile + self._profile = profile + + @property + def gecko_log(self): + if self._gecko_log: + return self._gecko_log + + path = self._gecko_log_option + if path != "-": + if path is None: + path = "gecko.log" + elif os.path.isdir(path): + fname = "gecko-{}.log".format(time.time()) + path = os.path.join(path, fname) + + path = os.path.realpath(path) + if os.access(path, os.F_OK): + os.remove(path) + + self._gecko_log = path + return self._gecko_log + + @property + def profile(self): + return self._profile + + @profile.setter + def profile(self, value): + self._update_profile(value) + + def _update_profile(self, profile=None, profile_name=None): + """Check if the profile has to be created, or replaced. + + :param profile: A Profile instance to be used. + :param name: Profile name to be used in the path. + """ + if self.runner and self.runner.is_running(): + raise errors.MarionetteException( + "The current profile can only be updated " + "when the instance is not running" + ) + + if isinstance(profile, Profile): + # Only replace the profile if it is not the current one + if hasattr(self, "_profile") and profile is self._profile: + return + + else: + profile_args = self.profile_args + profile_path = profile + + # If a path to a profile is given then clone it + if isinstance(profile_path, six.string_types): + profile_args["path_from"] = profile_path + profile_args["path_to"] = tempfile.mkdtemp( + suffix=u".{}".format( + profile_name or os.path.basename(profile_path) + ), + dir=self.workspace, + ) + # The target must not exist yet + os.rmdir(profile_args["path_to"]) + + profile = Profile.clone(**profile_args) + + # Otherwise create a new profile + else: + profile_args["profile"] = tempfile.mkdtemp( + suffix=u".{}".format(profile_name or "mozrunner"), + dir=self.workspace, + ) + profile = Profile(**profile_args) + profile.create_new = True + + if isinstance(self.profile, Profile): + self.profile.cleanup() + + self._profile = profile + + def switch_profile(self, profile_name=None, clone_from=None): + """Switch the profile by using the given name, and optionally clone it. + + Compared to :attr:`profile` this method allows to switch the profile + by giving control over the profile name as used for the new profile. It + also always creates a new blank profile, or as clone of an existent one. + + :param profile_name: Optional, name of the profile, which will be used + as part of the profile path (folder name containing the profile). + :clone_from: Optional, if specified the new profile will be cloned + based on the given profile. This argument can be an instance of + ``mozprofile.Profile``, or the path of the profile. + """ + if isinstance(clone_from, Profile): + clone_from = clone_from.profile + + self._update_profile(clone_from, profile_name=profile_name) + + @property + def profile_args(self): + args = {"preferences": deepcopy(self.required_prefs)} + args["preferences"]["marionette.port"] = self.marionette_port + args["preferences"]["marionette.defaultPrefs.port"] = self.marionette_port + + if self.prefs: + args["preferences"].update(self.prefs) + + if self.verbose: + level = "Trace" if self.verbose >= 2 else "Debug" + args["preferences"]["marionette.log.level"] = level + args["preferences"]["marionette.logging"] = level + + if "-jsdebugger" in self.app_args: + args["preferences"].update( + { + "devtools.browsertoolbox.panel": "jsdebugger", + "devtools.chrome.enabled": True, + "devtools.debugger.prompt-connection": False, + "devtools.debugger.remote-enabled": True, + "devtools.testing": True, + } + ) + + if self.addons: + args["addons"] = self.addons + + return args + + @classmethod + def create(cls, app=None, *args, **kwargs): + try: + if not app and kwargs["bin"] is not None: + app_id = mozversion.get_version(binary=kwargs["bin"])["application_id"] + app = app_ids[app_id] + + instance_class = apps[app] + except (IOError, KeyError): + exc, val, tb = sys.exc_info() + msg = 'Application "{0}" unknown (should be one of {1})'.format( + app, list(apps.keys()) + ) + reraise(NotImplementedError, NotImplementedError(msg), tb) + + return instance_class(*args, **kwargs) + + def start(self): + self._update_profile(self.profile) + self.runner = self.runner_class(**self._get_runner_args()) + self.runner.start() + + def _get_runner_args(self): + process_args = { + "processOutputLine": [NullOutput()], + "universal_newlines": True, + } + + if self.gecko_log == "-": + if hasattr(sys.stdout, "buffer"): + process_args["stream"] = codecs.getwriter("utf-8")(sys.stdout.buffer) + else: + process_args["stream"] = codecs.getwriter("utf-8")(sys.stdout) + else: + process_args["logfile"] = self.gecko_log + + env = os.environ.copy() + + # Store all required preferences for tests which need to create clean profiles. + required_prefs_keys = list(self.required_prefs.keys()) + env["MOZ_MARIONETTE_REQUIRED_PREFS"] = json.dumps(required_prefs_keys) + + if self.headless: + env["MOZ_HEADLESS"] = "1" + env["DISPLAY"] = "77" # Set a fake display. + + # environment variables needed for crashreporting + # https://developer.mozilla.org/docs/Environment_variables_affecting_crash_reporting + env.update( + { + "MOZ_CRASHREPORTER": "1", + "MOZ_CRASHREPORTER_NO_REPORT": "1", + "MOZ_CRASHREPORTER_SHUTDOWN": "1", + } + ) + + return { + "binary": self.binary, + "profile": self.profile, + "cmdargs": ["-no-remote", "-marionette"] + self.app_args, + "env": env, + "symbols_path": self.symbols_path, + "process_args": process_args, + } + + def close(self, clean=False): + """ + Close the managed Gecko process. + + Depending on self.runner_class, setting `clean` to True may also kill + the emulator process in which this instance is running. + + :param clean: If True, also perform runner cleanup. + """ + if self.runner: + self.runner.stop() + if clean: + self.runner.cleanup() + + if clean: + if isinstance(self.profile, Profile): + self.profile.cleanup() + self.profile = None + + def restart(self, prefs=None, clean=True): + """ + Close then start the managed Gecko process. + + :param prefs: Dictionary of preference names and values. + :param clean: If True, reset the profile before starting. + """ + if prefs: + self.prefs = prefs + else: + self.prefs = None + + self.close(clean=clean) + self.start() + + +class FennecInstance(GeckoInstance): + fennec_prefs = { + # Enable output for dump() and chrome console API + "browser.dom.window.dump.enabled": True, + "devtools.console.stdout.chrome": True, + # Disable safe browsing / tracking protection updates + "browser.safebrowsing.update.enabled": False, + # Do not restore the last open set of tabs if the browser has crashed + "browser.sessionstore.resume_from_crash": False, + # Disable e10s by default + "browser.tabs.remote.autostart": False, + # Do not allow background tabs to be zombified, otherwise for tests that + # open additional tabs, the test harness tab itself might get unloaded + "browser.tabs.disableBackgroundZombification": True, + } + + def __init__( + self, + emulator_binary=None, + avd_home=None, + avd=None, + adb_path=None, + serial=None, + connect_to_running_emulator=False, + package_name=None, + env=None, + *args, + **kwargs + ): + required_prefs = deepcopy(FennecInstance.fennec_prefs) + required_prefs.update(kwargs.get("prefs", {})) + + super(FennecInstance, self).__init__(*args, **kwargs) + self.required_prefs.update(required_prefs) + + self.runner_class = FennecEmulatorRunner + # runner args + self._package_name = package_name + self.emulator_binary = emulator_binary + self.avd_home = avd_home + self.adb_path = adb_path + self.avd = avd + self.env = env + self.serial = serial + self.connect_to_running_emulator = connect_to_running_emulator + + @property + def package_name(self): + """ + Name of app to run on emulator. + + Note that FennecInstance does not use self.binary + """ + if self._package_name is None: + self._package_name = "org.mozilla.fennec" + user = os.getenv("USER") + if user: + self._package_name += "_" + user + return self._package_name + + def start(self): + self._update_profile(self.profile) + self.runner = self.runner_class(**self._get_runner_args()) + try: + if self.connect_to_running_emulator: + self.runner.device.connect() + self.runner.start() + except Exception: + exc_cls, exc, tb = sys.exc_info() + reraise( + exc_cls, + exc_cls("Error possibly due to runner or device args: {}".format(exc)), + tb, + ) + + # forward marionette port + self.runner.device.device.forward( + local="tcp:{}".format(self.marionette_port), + remote="tcp:{}".format(self.marionette_port), + ) + + def _get_runner_args(self): + process_args = { + "processOutputLine": [NullOutput()], + "universal_newlines": True, + } + + env = {} if self.env is None else self.env.copy() + + runner_args = { + "app": self.package_name, + "avd_home": self.avd_home, + "adb_path": self.adb_path, + "binary": self.emulator_binary, + "env": env, + "profile": self.profile, + "cmdargs": ["-marionette"] + self.app_args, + "symbols_path": self.symbols_path, + "process_args": process_args, + "logdir": self.workspace or os.getcwd(), + "serial": self.serial, + } + if self.avd: + runner_args["avd"] = self.avd + + return runner_args + + def close(self, clean=False): + """ + Close the managed Gecko process. + + If `clean` is True and the Fennec instance is running in an + emulator managed by mozrunner, this will stop the emulator. + + :param clean: If True, also perform runner cleanup. + """ + super(FennecInstance, self).close(clean) + if clean and self.runner and self.runner.device.connected: + try: + self.runner.device.device.remove_forwards( + "tcp:{}".format(self.marionette_port) + ) + self.unresponsive_count = 0 + except Exception: + self.unresponsive_count += 1 + traceback.print_exception(*sys.exc_info()) + + +class DesktopInstance(GeckoInstance): + desktop_prefs = { + # Disable Firefox old build background check + "app.update.checkInstallTime": False, + # Disable automatically upgrading Firefox + # + # Note: Possible update tests could reset or flip the value to allow + # updates to be downloaded and applied. + "app.update.disabledForTesting": True, + # !!! For backward compatibility up to Firefox 64. Only remove + # when this Firefox version is no longer supported by the client !!! + "app.update.auto": False, + # Don't show the content blocking introduction panel + # We use a larger number than the default 22 to have some buffer + # This can be removed once Firefox 69 and 68 ESR and are no longer supported. + "browser.contentblocking.introCount": 99, + # Enable output for dump() and chrome console API + "browser.dom.window.dump.enabled": True, + "devtools.console.stdout.chrome": True, + # Indicate that the download panel has been shown once so that whichever + # download test runs first doesn"t show the popup inconsistently + "browser.download.panel.shown": True, + # Do not show the EULA notification which can interfer with tests + "browser.EULA.override": True, + # Disable Activity Stream telemetry pings + "browser.newtabpage.activity-stream.telemetry": False, + # Always display a blank page + "browser.newtabpage.enabled": False, + # Background thumbnails in particular cause grief, and disabling thumbnails + # in general can"t hurt - we re-enable them when tests need them + "browser.pagethumbnails.capturing_disabled": True, + # Disable safe browsing / tracking protection updates + "browser.safebrowsing.update.enabled": False, + # Disable updates to search engines + "browser.search.update": False, + # Do not restore the last open set of tabs if the browser has crashed + "browser.sessionstore.resume_from_crash": False, + # Don't check for the default web browser during startup + "browser.shell.checkDefaultBrowser": False, + # Disable session restore infobar + "browser.startup.couldRestoreSession.count": -1, + # Needed for branded builds to prevent opening a second tab on startup + "browser.startup.homepage_override.mstone": "ignore", + # Start with a blank page by default + "browser.startup.page": 0, + # Don't unload tabs when available memory is running low + "browser.tabs.unloadOnLowMemory": False, + # Do not warn when closing all open tabs + "browser.tabs.warnOnClose": False, + # Do not warn when closing all other open tabs + "browser.tabs.warnOnCloseOtherTabs": False, + # Do not warn when multiple tabs will be opened + "browser.tabs.warnOnOpen": False, + # Don't show the Bookmarks Toolbar on any tab (the above pref that + # disables the New Tab Page ends up showing the toolbar on about:blank). + "browser.toolbars.bookmarks.visibility": "never", + # Disable the UI tour + "browser.uitour.enabled": False, + # Turn off Merino suggestions in the location bar so as not to trigger network + # connections. + "browser.urlbar.merino.endpointURL": "", + # Turn off search suggestions in the location bar so as not to trigger network + # connections. + "browser.urlbar.suggest.searches": False, + # Don't warn when exiting the browser + "browser.warnOnQuit": False, + # Disable first-run welcome page + "startup.homepage_welcome_url": "about:blank", + "startup.homepage_welcome_url.additional": "", + } + + def __init__(self, *args, **kwargs): + required_prefs = deepcopy(DesktopInstance.desktop_prefs) + required_prefs.update(kwargs.get("prefs", {})) + + super(DesktopInstance, self).__init__(*args, **kwargs) + self.required_prefs.update(required_prefs) + + +class ThunderbirdInstance(GeckoInstance): + def __init__(self, *args, **kwargs): + super(ThunderbirdInstance, self).__init__(*args, **kwargs) + try: + # Copied alongside in the test archive + from .thunderbirdinstance import thunderbird_prefs + except ImportError: + try: + # Coming from source tree through virtualenv + from thunderbirdinstance import thunderbird_prefs + except ImportError: + thunderbird_prefs = {} + self.required_prefs.update(thunderbird_prefs) + + +class NullOutput(object): + def __call__(self, line): + pass + + +apps = { + "fennec": FennecInstance, + "fxdesktop": DesktopInstance, + "thunderbird": ThunderbirdInstance, +} + +app_ids = { + "{aa3c5121-dab2-40e2-81ca-7ea25febc110}": "fennec", + "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}": "fxdesktop", + "{3550f703-e582-4d05-9a08-453d09bdfdc6}": "thunderbird", +} diff --git a/testing/marionette/client/marionette_driver/keys.py b/testing/marionette/client/marionette_driver/keys.py new file mode 100644 index 0000000000..c53829b149 --- /dev/null +++ b/testing/marionette/client/marionette_driver/keys.py @@ -0,0 +1,88 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# copyright 2008-2009 WebDriver committers +# Copyright 2008-2009 Google Inc. +# +# Licensed under the Apache License Version 2.0 = uthe "License") +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http //www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing software +# distributed under the License is distributed on an "AS IS" BASIS +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class Keys(object): + + NULL = u"\ue000" + CANCEL = u"\ue001" # ^break + HELP = u"\ue002" + BACK_SPACE = u"\ue003" + TAB = u"\ue004" + CLEAR = u"\ue005" + RETURN = u"\ue006" + ENTER = u"\ue007" + SHIFT = u"\ue008" + LEFT_SHIFT = u"\ue008" # alias + CONTROL = u"\ue009" + LEFT_CONTROL = u"\ue009" # alias + ALT = u"\ue00a" + LEFT_ALT = u"\ue00a" # alias + PAUSE = u"\ue00b" + ESCAPE = u"\ue00c" + SPACE = u"\ue00d" + PAGE_UP = u"\ue00e" + PAGE_DOWN = u"\ue00f" + END = u"\ue010" + HOME = u"\ue011" + LEFT = u"\ue012" + ARROW_LEFT = u"\ue012" # alias + UP = u"\ue013" + ARROW_UP = u"\ue013" # alias + RIGHT = u"\ue014" + ARROW_RIGHT = u"\ue014" # alias + DOWN = u"\ue015" + ARROW_DOWN = u"\ue015" # alias + INSERT = u"\ue016" + DELETE = u"\ue017" + SEMICOLON = u"\ue018" + EQUALS = u"\ue019" + + NUMPAD0 = u"\ue01a" # numbe pad keys + NUMPAD1 = u"\ue01b" + NUMPAD2 = u"\ue01c" + NUMPAD3 = u"\ue01d" + NUMPAD4 = u"\ue01e" + NUMPAD5 = u"\ue01f" + NUMPAD6 = u"\ue020" + NUMPAD7 = u"\ue021" + NUMPAD8 = u"\ue022" + NUMPAD9 = u"\ue023" + MULTIPLY = u"\ue024" + ADD = u"\ue025" + SEPARATOR = u"\ue026" + SUBTRACT = u"\ue027" + DECIMAL = u"\ue028" + DIVIDE = u"\ue029" + + F1 = u"\ue031" # function keys + F2 = u"\ue032" + F3 = u"\ue033" + F4 = u"\ue034" + F5 = u"\ue035" + F6 = u"\ue036" + F7 = u"\ue037" + F8 = u"\ue038" + F9 = u"\ue039" + F10 = u"\ue03a" + F11 = u"\ue03b" + F12 = u"\ue03c" + + META = u"\ue03d" + COMMAND = u"\ue03d" diff --git a/testing/marionette/client/marionette_driver/localization.py b/testing/marionette/client/marionette_driver/localization.py new file mode 100644 index 0000000000..fccb32f416 --- /dev/null +++ b/testing/marionette/client/marionette_driver/localization.py @@ -0,0 +1,54 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + + +class L10n(object): + """An API which allows Marionette to handle localized content. + + The `localization`_ of UI elements in Gecko based applications is done via + entities and properties. For static values entities are used, which are located + in .dtd files. Whereby for dynamically updated content the values come from + .property files. Both types of elements can be identifed via a unique id, + and the translated content retrieved. + + For example:: + + from marionette_driver.localization import L10n + l10n = L10n(marionette) + + l10n.localize_entity(["chrome://branding/locale/brand.dtd"], "brandShortName") + l10n.localize_property(["chrome://global/locale/findbar.properties"], "FastFind")) + + .. _localization: https://mzl.la/2eUMjyF + """ + + def __init__(self, marionette): + self._marionette = marionette + + def localize_entity(self, dtd_urls, entity_id): + """Retrieve the localized string for the specified entity id. + + :param dtd_urls: List of .dtd URLs which will be used to search for the entity. + :param entity_id: ID of the entity to retrieve the localized string for. + + :returns: The localized string for the requested entity. + :raises: :exc:`NoSuchElementException` + """ + body = {"urls": dtd_urls, "id": entity_id} + return self._marionette._send_message("L10n:LocalizeEntity", body, key="value") + + def localize_property(self, properties_urls, property_id): + """Retrieve the localized string for the specified property id. + + :param properties_urls: List of .properties URLs which will be used to + search for the property. + :param property_id: ID of the property to retrieve the localized string for. + + :returns: The localized string for the requested property. + :raises: :exc:`NoSuchElementException` + """ + body = {"urls": properties_urls, "id": property_id} + return self._marionette._send_message( + "L10n:LocalizeProperty", body, key="value" + ) diff --git a/testing/marionette/client/marionette_driver/marionette.py b/testing/marionette/client/marionette_driver/marionette.py new file mode 100644 index 0000000000..f7bed9b68d --- /dev/null +++ b/testing/marionette/client/marionette_driver/marionette.py @@ -0,0 +1,2064 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import base64 +import datetime +import json +import os +import socket +import sys +import time +import traceback +from contextlib import contextmanager + +import six +from six import reraise + +from . import errors, transport +from .decorators import do_process_check +from .geckoinstance import GeckoInstance +from .keys import Keys +from .timeout import Timeouts + +FRAME_KEY = "frame-075b-4da1-b6ba-e579c2d3230a" +WEB_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf" +WEB_SHADOW_ROOT_KEY = "shadow-6066-11e4-a52e-4f735466cecf" +WINDOW_KEY = "window-fcc6-11e5-b4f8-330a88ab9d7f" + + +class MouseButton(object): + """Enum-like class for mouse button constants.""" + + LEFT = 0 + MIDDLE = 1 + RIGHT = 2 + + +class ActionSequence(object): + r"""API for creating and performing action sequences. + + Each action method adds one or more actions to a queue. When perform() + is called, the queued actions fire in order. + + May be chained together as in:: + + ActionSequence(self.marionette, "key", id) \ + .key_down("a") \ + .key_up("a") \ + .perform() + """ + + def __init__(self, marionette, action_type, input_id, pointer_params=None): + self.marionette = marionette + self._actions = [] + self._id = input_id + self._pointer_params = pointer_params + self._type = action_type + + @property + def dict(self): + d = { + "type": self._type, + "id": self._id, + "actions": self._actions, + } + if self._pointer_params is not None: + d["parameters"] = self._pointer_params + return d + + def perform(self): + """Perform all queued actions.""" + self.marionette.actions.perform([self.dict]) + + def _key_action(self, subtype, value): + self._actions.append({"type": subtype, "value": value}) + + def _pointer_action(self, subtype, button): + self._actions.append({"type": subtype, "button": button}) + + def pause(self, duration): + self._actions.append({"type": "pause", "duration": duration}) + return self + + def pointer_move(self, x, y, duration=None, origin=None): + """Queue a pointerMove action. + + :param x: Destination x-axis coordinate of pointer in CSS pixels. + :param y: Destination y-axis coordinate of pointer in CSS pixels. + :param duration: Number of milliseconds over which to distribute the + move. If None, remote end defaults to 0. + :param origin: Origin of coordinates, either "viewport", "pointer" or + an Element. If None, remote end defaults to "viewport". + """ + action = {"type": "pointerMove", "x": x, "y": y} + if duration is not None: + action["duration"] = duration + if origin is not None: + if isinstance(origin, HTMLElement): + action["origin"] = {origin.kind: origin.id} + else: + action["origin"] = origin + self._actions.append(action) + return self + + def pointer_up(self, button=MouseButton.LEFT): + """Queue a pointerUp action for `button`. + + :param button: Pointer button to perform action with. + Default: 0, which represents main device button. + """ + self._pointer_action("pointerUp", button) + return self + + def pointer_down(self, button=MouseButton.LEFT): + """Queue a pointerDown action for `button`. + + :param button: Pointer button to perform action with. + Default: 0, which represents main device button. + """ + self._pointer_action("pointerDown", button) + return self + + def click(self, element=None, button=MouseButton.LEFT): + """Queue a click with the specified button. + + If an element is given, move the pointer to that element first, + otherwise click current pointer coordinates. + + :param element: Optional element to click. + :param button: Integer representing pointer button to perform action + with. Default: 0, which represents main device button. + """ + if element: + self.pointer_move(0, 0, origin=element) + return self.pointer_down(button).pointer_up(button) + + def key_down(self, value): + """Queue a keyDown action for `value`. + + :param value: Single character to perform key action with. + """ + self._key_action("keyDown", value) + return self + + def key_up(self, value): + """Queue a keyUp action for `value`. + + :param value: Single character to perform key action with. + """ + self._key_action("keyUp", value) + return self + + def send_keys(self, keys): + """Queue a keyDown and keyUp action for each character in `keys`. + + :param keys: String of keys to perform key actions with. + """ + for c in keys: + self.key_down(c) + self.key_up(c) + return self + + +class Actions(object): + def __init__(self, marionette): + self.marionette = marionette + + def perform(self, actions=None): + """Perform actions by tick from each action sequence in `actions`. + + :param actions: List of input source action sequences. A single action + sequence may be created with the help of + ``ActionSequence.dict``. + """ + body = {"actions": [] if actions is None else actions} + return self.marionette._send_message("WebDriver:PerformActions", body) + + def release(self): + return self.marionette._send_message("WebDriver:ReleaseActions") + + def sequence(self, *args, **kwargs): + """Return an empty ActionSequence of the designated type. + + See ActionSequence for parameter list. + """ + return ActionSequence(self.marionette, *args, **kwargs) + + +class HTMLElement(object): + """Represents a DOM Element.""" + + identifiers = (FRAME_KEY, WINDOW_KEY, WEB_ELEMENT_KEY) + + def __init__(self, marionette, id, kind=WEB_ELEMENT_KEY): + self.marionette = marionette + assert id is not None + self.id = id + self.kind = kind + + def __str__(self): + return self.id + + def __eq__(self, other_element): + return self.id == other_element.id + + def __hash__(self): + # pylint --py3k: W1641 + return hash(self.id) + + def find_element(self, method, target): + """Returns an ``HTMLElement`` instance that matches the specified + method and target, relative to the current element. + + For more details on this function, see the + :func:`~marionette_driver.marionette.Marionette.find_element` method + in the Marionette class. + """ + return self.marionette.find_element(method, target, self.id) + + def find_elements(self, method, target): + """Returns a list of all ``HTMLElement`` instances that match the + specified method and target in the current context. + + For more details on this function, see the + :func:`~marionette_driver.marionette.Marionette.find_elements` method + in the Marionette class. + """ + return self.marionette.find_elements(method, target, self.id) + + def get_attribute(self, name): + """Returns the requested attribute, or None if no attribute + is set. + """ + body = {"id": self.id, "name": name} + return self.marionette._send_message( + "WebDriver:GetElementAttribute", body, key="value" + ) + + def get_property(self, name): + """Returns the requested property, or None if the property is + not set. + """ + try: + body = {"id": self.id, "name": name} + return self.marionette._send_message( + "WebDriver:GetElementProperty", body, key="value" + ) + except errors.UnknownCommandException: + # Keep backward compatibility for code which uses get_attribute() to + # also retrieve element properties. + # Remove when Firefox 55 is stable. + return self.get_attribute(name) + + def click(self): + """Simulates a click on the element.""" + self.marionette._send_message("WebDriver:ElementClick", {"id": self.id}) + + def tap(self, x=None, y=None): + """Simulates a set of tap events on the element. + + :param x: X coordinate of tap event. If not given, default to + the centre of the element. + :param y: Y coordinate of tap event. If not given, default to + the centre of the element. + """ + body = {"id": self.id, "x": x, "y": y} + self.marionette._send_message("Marionette:SingleTap", body) + + @property + def text(self): + """Returns the visible text of the element, and its child elements.""" + body = {"id": self.id} + return self.marionette._send_message( + "WebDriver:GetElementText", body, key="value" + ) + + def send_keys(self, *strings): + """Sends the string via synthesized keypresses to the element. + If an array is passed in like `marionette.send_keys(Keys.SHIFT, "a")` it + will be joined into a string. + If an integer is passed in like `marionette.send_keys(1234)` it will be + coerced into a string. + """ + keys = Marionette.convert_keys(*strings) + self.marionette._send_message( + "WebDriver:ElementSendKeys", {"id": self.id, "text": keys} + ) + + def clear(self): + """Clears the input of the element.""" + self.marionette._send_message("WebDriver:ElementClear", {"id": self.id}) + + def is_selected(self): + """Returns True if the element is selected.""" + body = {"id": self.id} + return self.marionette._send_message( + "WebDriver:IsElementSelected", body, key="value" + ) + + def is_enabled(self): + """This command will return False if all the following criteria + are met otherwise return True: + + * A form control is disabled. + * A ``HTMLElement`` has a disabled boolean attribute. + """ + body = {"id": self.id} + return self.marionette._send_message( + "WebDriver:IsElementEnabled", body, key="value" + ) + + def is_displayed(self): + """Returns True if the element is displayed, False otherwise.""" + body = {"id": self.id} + return self.marionette._send_message( + "WebDriver:IsElementDisplayed", body, key="value" + ) + + @property + def tag_name(self): + """The tag name of the element.""" + body = {"id": self.id} + return self.marionette._send_message( + "WebDriver:GetElementTagName", body, key="value" + ) + + @property + def rect(self): + """Gets the element's bounding rectangle. + + This will return a dictionary with the following: + + * x and y represent the top left coordinates of the ``HTMLElement`` + relative to top left corner of the document. + * height and the width will contain the height and the width + of the DOMRect of the ``HTMLElement``. + """ + return self.marionette._send_message( + "WebDriver:GetElementRect", {"id": self.id} + ) + + def value_of_css_property(self, property_name): + """Gets the value of the specified CSS property name. + + :param property_name: Property name to get the value of. + """ + body = {"id": self.id, "propertyName": property_name} + return self.marionette._send_message( + "WebDriver:GetElementCSSValue", body, key="value" + ) + + @property + def shadow_root(self): + """Gets the shadow root of the current element""" + return self.marionette._send_message( + "WebDriver:GetShadowRoot", {"id": self.id}, key="value" + ) + + @classmethod + def _from_json(cls, json, marionette): + if isinstance(json, dict): + if WEB_ELEMENT_KEY in json: + return cls(marionette, json[WEB_ELEMENT_KEY], WEB_ELEMENT_KEY) + elif FRAME_KEY in json: + return cls(marionette, json[FRAME_KEY], FRAME_KEY) + elif WINDOW_KEY in json: + return cls(marionette, json[WINDOW_KEY], WINDOW_KEY) + raise ValueError("Unrecognised web element") + + +class ShadowRoot(object): + """A Class to handling Shadow Roots""" + + identifiers = (WEB_SHADOW_ROOT_KEY,) + + def __init__(self, marionette, id, kind=WEB_SHADOW_ROOT_KEY): + self.marionette = marionette + assert id is not None + self.id = id + self.kind = kind + + def __str__(self): + return self.id + + def __eq__(self, other_element): + return self.id == other_element.id + + def __hash__(self): + # pylint --py3k: W1641 + return hash(self.id) + + @classmethod + def _from_json(cls, json, marionette): + if isinstance(json, dict): + if WEB_SHADOW_ROOT_KEY in json: + return cls(marionette, json[WEB_SHADOW_ROOT_KEY]) + raise ValueError("Unrecognised shadow root") + + +class Alert(object): + """A class for interacting with alerts. + + :: + + Alert(marionette).accept() + Alert(marionette).dismiss() + """ + + def __init__(self, marionette): + self.marionette = marionette + + def accept(self): + """Accept a currently displayed modal dialog.""" + self.marionette._send_message("WebDriver:AcceptAlert") + + def dismiss(self): + """Dismiss a currently displayed modal dialog.""" + self.marionette._send_message("WebDriver:DismissAlert") + + @property + def text(self): + """Return the currently displayed text in a tab modal.""" + return self.marionette._send_message("WebDriver:GetAlertText", key="value") + + def send_keys(self, *string): + """Send keys to the currently displayed text input area in an open + tab modal dialog.""" + self.marionette._send_message( + "WebDriver:SendAlertText", {"text": Marionette.convert_keys(*string)} + ) + + +class Marionette(object): + """Represents a Marionette connection to a browser or device.""" + + CONTEXT_CHROME = "chrome" # non-browser content: windows, dialogs, etc. + CONTEXT_CONTENT = "content" # browser content: iframes, divs, etc. + DEFAULT_STARTUP_TIMEOUT = 120 + DEFAULT_SHUTDOWN_TIMEOUT = ( + 70 # By default Firefox will kill hanging threads after 60s + ) + + # Bug 1336953 - Until we can remove the socket timeout parameter it has to be + # set a default value which is larger than the longest timeout as defined by the + # WebDriver spec. In that case its 300s for page load. Also add another minute + # so that slow builds have enough time to send the timeout error to the client. + DEFAULT_SOCKET_TIMEOUT = 360 + + def __init__( + self, + host="127.0.0.1", + port=2828, + app=None, + bin=None, + baseurl=None, + socket_timeout=None, + startup_timeout=None, + **instance_args + ): + """Construct a holder for the Marionette connection. + + Remember to call ``start_session`` in order to initiate the + connection and start a Marionette session. + + :param host: Host where the Marionette server listens. + Defaults to 127.0.0.1. + :param port: Port where the Marionette server listens. + Defaults to port 2828. + :param baseurl: Where to look for files served from Marionette's + www directory. + :param socket_timeout: Timeout for Marionette socket operations. + :param startup_timeout: Seconds to wait for a connection with + binary. + :param bin: Path to browser binary. If any truthy value is given + this will attempt to start a Gecko instance with the specified + `app`. + :param app: Type of ``instance_class`` to use for managing app + instance. See ``marionette_driver.geckoinstance``. + :param instance_args: Arguments to pass to ``instance_class``. + """ + self.host = "127.0.0.1" # host + if int(port) == 0: + port = Marionette.check_port_available(port) + self.port = self.local_port = int(port) + self.bin = bin + self.client = None + self.instance = None + self.requested_capabilities = None + self.session = None + self.session_id = None + self.process_id = None + self.profile = None + self.window = None + self.chrome_window = None + self.baseurl = baseurl + self._test_name = None + self.crashed = 0 + self.is_shutting_down = False + self.cleanup_ran = False + + if socket_timeout is None: + self.socket_timeout = self.DEFAULT_SOCKET_TIMEOUT + else: + self.socket_timeout = float(socket_timeout) + + if startup_timeout is None: + self.startup_timeout = self.DEFAULT_STARTUP_TIMEOUT + else: + self.startup_timeout = int(startup_timeout) + + self.shutdown_timeout = self.DEFAULT_SHUTDOWN_TIMEOUT + + if self.bin: + self.instance = GeckoInstance.create( + app, host=self.host, port=self.port, bin=self.bin, **instance_args + ) + self.start_binary(self.startup_timeout) + + self.actions = Actions(self) + self.timeout = Timeouts(self) + + @property + def profile_path(self): + if self.instance and self.instance.profile: + return self.instance.profile.profile + + def start_binary(self, timeout): + try: + self.check_port_available(self.port, host=self.host) + except socket.error: + _, value, tb = sys.exc_info() + msg = "Port {}:{} is unavailable ({})".format(self.host, self.port, value) + reraise(IOError, IOError(msg), tb) + + try: + self.instance.start() + self.raise_for_port(timeout=timeout) + except socket.timeout: + # Something went wrong with starting up Marionette server. Given + # that the process will not quit itself, force a shutdown immediately. + self.cleanup() + + msg = ( + "Process killed after {}s because no connection to Marionette " + "server could be established. Check gecko.log for errors" + ) + reraise(IOError, IOError(msg.format(timeout)), sys.exc_info()[2]) + + def cleanup(self): + if self.session is not None: + try: + self.delete_session() + except (errors.MarionetteException, IOError): + # These exceptions get thrown if the Marionette server + # hit an exception/died or the connection died. We can + # do no further server-side cleanup in this case. + pass + + if self.instance: + # stop application and, if applicable, stop emulator + self.instance.close(clean=True) + if self.instance.unresponsive_count >= 3: + raise errors.UnresponsiveInstanceException( + "Application clean-up has failed >2 consecutive times." + ) + + self.cleanup_ran = True + + def __del__(self): + if not self.cleanup_ran: + self.cleanup() + + @staticmethod + def check_port_available(port, host=""): + """Check if "host:port" is available. + + Raise socket.error if port is not available. + """ + port = int(port) + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + s.bind((host, port)) + port = s.getsockname()[1] + finally: + s.close() + return port + + def raise_for_port(self, timeout=None, check_process_status=True): + """Raise socket.timeout if no connection can be established. + + :param timeout: Optional timeout in seconds for the server to be ready. + :param check_process_status: Optional, if `True` the process will be + continuously checked if it has exited, and the connection + attempt will be aborted. + """ + if timeout is None: + timeout = self.startup_timeout + + runner = None + if self.instance is not None: + runner = self.instance.runner + + poll_interval = 0.1 + starttime = datetime.datetime.now() + timeout_time = starttime + datetime.timedelta(seconds=timeout) + + client = transport.TcpTransport(self.host, self.port, 0.5) + + connected = False + while datetime.datetime.now() < timeout_time: + # If the instance we want to connect to is not running return immediately + if check_process_status and runner is not None and not runner.is_running(): + break + + try: + client.connect() + return True + except socket.error: + pass + finally: + client.close() + + time.sleep(poll_interval) + + if not connected: + # There might have been a startup crash of the application + if runner is not None and self.check_for_crash() > 0: + raise IOError("Process crashed (Exit code: {})".format(runner.wait(0))) + + raise socket.timeout( + "Timed out waiting for connection on {0}:{1}!".format( + self.host, self.port + ) + ) + + @do_process_check + def _send_message(self, name, params=None, key=None): + """Send a blocking message to the server. + + Marionette provides an asynchronous, non-blocking interface and + this attempts to paper over this by providing a synchronous API + to the user. + + :param name: Requested command key. + :param params: Optional dictionary of key/value arguments. + :param key: Optional key to extract from response. + + :returns: Full response from the server, or if `key` is given, + the value of said key in the response. + """ + if not self.session_id and name != "WebDriver:NewSession": + raise errors.InvalidSessionIdException("Please start a session") + + try: + msg = self.client.request(name, params) + except IOError: + self.delete_session(send_request=False) + raise + + res, err = msg.result, msg.error + if err: + self._handle_error(err) + + if key is not None: + return self._from_json(res.get(key)) + else: + return self._from_json(res) + + def _handle_error(self, obj): + error = obj["error"] + message = obj["message"] + stacktrace = obj["stacktrace"] + + raise errors.lookup(error)(message, stacktrace=stacktrace) + + def check_for_crash(self): + """Check if the process crashed. + + :returns: True, if a crash happened since the method has been called the last time. + """ + crash_count = 0 + + if self.instance: + name = self.test_name or "marionette.py" + crash_count = self.instance.runner.check_for_crashes(test_name=name) + self.crashed = self.crashed + crash_count + + return crash_count > 0 + + def _handle_socket_failure(self): + """Handle socket failures for the currently connected application. + + If the application crashed then clean-up internal states, or in case of a content + crash also kill the process. If there are other reasons for a socket failure, + wait for the process to shutdown itself, or force kill it. + + Please note that the method expects an exception to be handled on the current stack + frame, and is only called via the `@do_process_check` decorator. + + """ + exc_cls, exc, tb = sys.exc_info() + + # If the application hasn't been launched by Marionette no further action can be done. + # In such cases we simply re-throw the exception. + if not self.instance: + reraise(exc_cls, exc, tb) + + else: + # Somehow the socket disconnected. Give the application some time to shutdown + # itself before killing the process. + returncode = self.instance.runner.wait(timeout=self.shutdown_timeout) + + if returncode is None: + message = ( + "Process killed because the connection to Marionette server is " + "lost. Check gecko.log for errors" + ) + # This will force-close the application without sending any other message. + self.cleanup() + else: + # If Firefox quit itself check if there was a crash + crash_count = self.check_for_crash() + + if crash_count > 0: + # SIGUSR1 indicates a forced shutdown due to a content process crash + if returncode == 245: + message = "Content process crashed" + else: + message = "Process crashed (Exit code: {returncode})" + else: + message = ( + "Process has been unexpectedly closed (Exit code: {returncode})" + ) + + self.delete_session(send_request=False) + + message += " (Reason: {reason})" + + reraise( + IOError, IOError(message.format(returncode=returncode, reason=exc)), tb + ) + + @staticmethod + def convert_keys(*string): + typing = [] + for val in string: + if isinstance(val, Keys): + typing.append(val) + elif isinstance(val, int): + val = str(val) + for i in range(len(val)): + typing.append(val[i]) + else: + for i in range(len(val)): + typing.append(val[i]) + return "".join(typing) + + def clear_pref(self, pref): + """Clear the user-defined value from the specified preference. + + :param pref: Name of the preference. + """ + with self.using_context(self.CONTEXT_CHROME): + self.execute_script( + """ + const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" + ); + Preferences.reset(arguments[0]); + """, + script_args=(pref,), + ) + + def get_pref(self, pref, default_branch=False, value_type="unspecified"): + """Get the value of the specified preference. + + :param pref: Name of the preference. + :param default_branch: Optional, if `True` the preference value will be read + from the default branch. Otherwise the user-defined + value if set is returned. Defaults to `False`. + :param value_type: Optional, XPCOM interface of the pref's complex value. + Possible values are: `nsIFile` and + `nsIPrefLocalizedString`. + + Usage example:: + + marionette.get_pref("browser.tabs.warnOnClose") + + """ + with self.using_context(self.CONTEXT_CHROME): + pref_value = self.execute_script( + """ + const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" + ); + + let pref = arguments[0]; + let defaultBranch = arguments[1]; + let valueType = arguments[2]; + + prefs = new Preferences({defaultBranch: defaultBranch}); + return prefs.get(pref, null, Components.interfaces[valueType]); + """, + script_args=(pref, default_branch, value_type), + ) + return pref_value + + def set_pref(self, pref, value, default_branch=False): + """Set the value of the specified preference. + + :param pref: Name of the preference. + :param value: The value to set the preference to. If the value is None, + reset the preference to its default value. If no default + value exists, the preference will cease to exist. + :param default_branch: Optional, if `True` the preference value will + be written to the default branch, and will remain until + the application gets restarted. Otherwise a user-defined + value is set. Defaults to `False`. + + Usage example:: + + marionette.set_pref("browser.tabs.warnOnClose", True) + + """ + with self.using_context(self.CONTEXT_CHROME): + if value is None: + self.clear_pref(pref) + return + + self.execute_script( + """ + const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" + ); + + let pref = arguments[0]; + let value = arguments[1]; + let defaultBranch = arguments[2]; + + prefs = new Preferences({defaultBranch: defaultBranch}); + prefs.set(pref, value); + """, + script_args=(pref, value, default_branch), + ) + + def set_prefs(self, prefs, default_branch=False): + """Set the value of a list of preferences. + + :param prefs: A dict containing one or more preferences and their values + to be set. See :func:`set_pref` for further details. + :param default_branch: Optional, if `True` the preference value will + be written to the default branch, and will remain until + the application gets restarted. Otherwise a user-defined + value is set. Defaults to `False`. + + Usage example:: + + marionette.set_prefs({"browser.tabs.warnOnClose": True}) + + """ + for pref, value in prefs.items(): + self.set_pref(pref, value, default_branch=default_branch) + + @contextmanager + def using_prefs(self, prefs, default_branch=False): + """Set preferences for code executed in a `with` block, and restores them on exit. + + :param prefs: A dict containing one or more preferences and their values + to be set. See :func:`set_prefs` for further details. + :param default_branch: Optional, if `True` the preference value will + be written to the default branch, and will remain until + the application gets restarted. Otherwise a user-defined + value is set. Defaults to `False`. + + Usage example:: + + with marionette.using_prefs({"browser.tabs.warnOnClose": True}): + # ... do stuff ... + + """ + original_prefs = {p: self.get_pref(p) for p in prefs} + self.set_prefs(prefs, default_branch=default_branch) + + try: + yield + finally: + self.set_prefs(original_prefs, default_branch=default_branch) + + @do_process_check + def enforce_gecko_prefs(self, prefs): + """Checks if the running instance has the given prefs. If not, + it will kill the currently running instance, and spawn a new + instance with the requested preferences. + + :param prefs: A dictionary whose keys are preference names. + """ + if not self.instance: + raise errors.MarionetteException( + "enforce_gecko_prefs() can only be called " + "on Gecko instances launched by Marionette" + ) + pref_exists = True + with self.using_context(self.CONTEXT_CHROME): + for pref, value in six.iteritems(prefs): + if type(value) is not str: + value = json.dumps(value) + pref_exists = self.execute_script( + """ + let prefInterface = Components.classes["@mozilla.org/preferences-service;1"] + .getService(Components.interfaces.nsIPrefBranch); + let pref = '{0}'; + let value = '{1}'; + let type = prefInterface.getPrefType(pref); + switch(type) {{ + case prefInterface.PREF_STRING: + return value == prefInterface.getCharPref(pref).toString(); + case prefInterface.PREF_BOOL: + return value == prefInterface.getBoolPref(pref).toString(); + case prefInterface.PREF_INT: + return value == prefInterface.getIntPref(pref).toString(); + case prefInterface.PREF_INVALID: + return false; + }} + """.format( + pref, value + ) + ) + if not pref_exists: + break + + if not pref_exists: + context = self._send_message("Marionette:GetContext", key="value") + self.delete_session() + self.instance.restart(prefs) + self.raise_for_port() + self.start_session(self.requested_capabilities) + + # Restore the context as used before the restart + self.set_context(context) + + def _request_in_app_shutdown(self, flags=None, safe_mode=False): + """Attempt to quit the currently running instance from inside the + application. If shutdown is prevented by some component the quit + will be forced. + + This method effectively calls `Services.startup.quit` in Gecko. + Possible flag values are listed at https://bit.ly/3IYcjYi. + + :param flags: Optional additional quit masks to include. + + :param safe_mode: Optional flag to indicate that the application has to + be restarted in safe mode. + + :returns: A dictionary containing details of the application shutdown. + The `cause` property reflects the reason, and `forced` indicates + that something prevented the shutdown and the application had + to be forced to shutdown. + + :throws InvalidArgumentException: If there are multiple + `shutdown_flags` ending with `"Quit"`. + """ + body = {} + if flags is not None: + body["flags"] = list( + flags, + ) + if safe_mode: + body["safeMode"] = safe_mode + + return self._send_message("Marionette:Quit", body) + + @do_process_check + def quit(self, clean=False, in_app=True, callback=None): + """ + By default this method will trigger a normal shutdown of the currently running instance. + But it can also be used to force terminate the process. + + This command will delete the active marionette session. It also allows + manipulation of eg. the profile data while the application is not running. + To start the application again, :func:`start_session` has to be called. + + :param clean: If True a new profile will be used after the next start of + the application. Note that the in_app initiated quit always + maintains the same profile. + + :param in_app: If True, marionette will cause a quit from within the + application. Otherwise the application will be restarted + immediately by killing the process. + + :param callback: If provided and `in_app` is True, the callback will + be used to trigger the shutdown. + + :returns: A dictionary containing details of the application shutdown. + The `cause` property reflects the reason, and `forced` indicates + that something prevented the shutdown and the application had + to be forced to shutdown. + """ + if not self.instance: + raise errors.MarionetteException( + "quit() can only be called " "on Gecko instances launched by Marionette" + ) + + quit_details = {"cause": "shutdown", "forced": False} + + if in_app: + if clean: + raise ValueError( + "An in_app restart cannot be triggered with the clean flag set" + ) + + if callback is not None and not callable(callback): + raise ValueError( + "Specified callback '{}' is not callable".format(callback) + ) + + # Block Marionette from accepting new connections + self._send_message("Marionette:AcceptConnections", {"value": False}) + + try: + self.is_shutting_down = True + if callback is not None: + callback() + quit_details["in_app"] = True + else: + quit_details = self._request_in_app_shutdown() + + except IOError: + # A possible IOError should be ignored at this point, given that + # quit() could have been called inside of `using_context`, + # which wants to reset the context but fails sending the message. + pass + + except Exception: + # For any other error assume the application is not going to shutdown. + # As such allow Marionette to accept new connections again. + self.is_shutting_down = False + self._send_message("Marionette:AcceptConnections", {"value": True}) + raise + + try: + self.delete_session(send_request=False) + + # Try to wait for the process to end itself before force-closing it. + returncode = self.instance.runner.wait(timeout=self.shutdown_timeout) + if returncode is None: + self.cleanup() + + message = "Process still running {}s after quit request" + raise IOError(message.format(self.shutdown_timeout)) + + finally: + self.is_shutting_down = False + + else: + self.delete_session(send_request=False) + self.instance.close(clean=clean) + + quit_details.update({"in_app": False, "forced": True}) + + if quit_details.get("cause") not in (None, "shutdown"): + raise errors.MarionetteException( + "Unexpected shutdown reason '{}' for " + "quitting the process.".format(quit_details["cause"]) + ) + + return quit_details + + @do_process_check + def restart( + self, callback=None, clean=False, in_app=True, safe_mode=False, silent=False + ): + """ + By default this method will restart the currently running instance by using the same + profile. But it can also be forced to terminate the currently running instance, and + to spawn a new instance with the same or different profile. + + :param callback: If provided and `in_app` is True, the callback will be + used to trigger the restart. + + :param clean: If True a new profile will be used after the restart. Note + that the in_app initiated restart always maintains the same + profile. + + :param in_app: If True, marionette will cause a restart from within the + application. Otherwise the application will be restarted + immediately by killing the process. + + :param safe_mode: Optional flag to indicate that the application has to + be restarted in safe mode. + + :param silent: Optional flag to indicate that the application should + not open any window after a restart. Note that this flag is only + supported on MacOS and requires "in_app" to be True. + + :returns: A dictionary containing details of the application restart. + The `cause` property reflects the reason, and `forced` indicates + that something prevented the shutdown and the application had + to be forced to shutdown. + """ + if not self.instance: + raise errors.MarionetteException( + "restart() can only be called " + "on Gecko instances launched by Marionette" + ) + + context = self._send_message("Marionette:GetContext", key="value") + restart_details = {"cause": "restart", "forced": False} + + # Safe mode and the silent flag require an in_app restart. + if (safe_mode or silent) and not in_app: + raise ValueError("An in_app restart is required for safe or silent mode") + + if in_app: + if clean: + raise ValueError( + "An in_app restart cannot be triggered with the clean flag set" + ) + + if callback is not None and not callable(callback): + raise ValueError( + "Specified callback '{}' is not callable".format(callback) + ) + + # Block Marionette from accepting new connections + self._send_message("Marionette:AcceptConnections", {"value": False}) + + try: + self.is_shutting_down = True + if callback is not None: + callback() + restart_details["in_app"] = True + else: + flags = ["eRestart"] + if silent: + flags.append("eSilently") + + try: + restart_details = self._request_in_app_shutdown( + flags=flags, safe_mode=safe_mode + ) + except Exception as e: + self._send_message( + "Marionette:AcceptConnections", {"value": True} + ) + raise e + + except IOError: + # A possible IOError should be ignored at this point, given that + # restart() could have been called inside of `using_context`, + # which wants to reset the context but fails sending the message. + pass + + timeout_restart = self.shutdown_timeout + self.startup_timeout + try: + # Wait for a new Marionette connection to appear while the + # process restarts itself. + self.raise_for_port(timeout=timeout_restart, check_process_status=False) + except socket.timeout: + exc_cls, _, tb = sys.exc_info() + + if self.instance.runner.returncode is None: + # The process is still running, which means the shutdown + # request was not correct or the application ignored it. + # Allow Marionette to accept connections again. + self._send_message("Marionette:AcceptConnections", {"value": True}) + + message = "Process still running {}s after restart request" + reraise(exc_cls, exc_cls(message.format(timeout_restart)), tb) + + else: + # The process shutdown but didn't start again. + self.cleanup() + msg = "Process unexpectedly quit without restarting (exit code: {})" + reraise( + exc_cls, + exc_cls(msg.format(self.instance.runner.returncode)), + tb, + ) + + finally: + self.is_shutting_down = False + + self.delete_session(send_request=False) + + else: + self.delete_session() + self.instance.restart(clean=clean) + self.raise_for_port(timeout=self.DEFAULT_STARTUP_TIMEOUT) + + restart_details.update({"in_app": False, "forced": True}) + + if restart_details.get("cause") not in (None, "restart"): + raise errors.MarionetteException( + "Unexpected shutdown reason '{}' for " + "restarting the process".format(restart_details["cause"]) + ) + + self.start_session(self.requested_capabilities) + # Restore the context as used before the restart + self.set_context(context) + + if in_app and self.process_id: + # In some cases Firefox restarts itself by spawning into a new process group. + # As long as mozprocess cannot track that behavior (bug 1284864) we assist by + # informing about the new process id. + self.instance.runner.process_handler.check_for_detached(self.process_id) + + return restart_details + + def absolute_url(self, relative_url): + """ + Returns an absolute url for files served from Marionette's www directory. + + :param relative_url: The url of a static file, relative to Marionette's www directory. + """ + return "{0}{1}".format(self.baseurl, relative_url) + + @do_process_check + def start_session(self, capabilities=None, timeout=None): + """Create a new WebDriver session. + This method must be called before performing any other action. + + :param capabilities: An optional dictionary of + Marionette-recognised capabilities. It does not + accept a WebDriver conforming capabilities dictionary + (including alwaysMatch, firstMatch, desiredCapabilities, + or requriedCapabilities), and only recognises extension + capabilities that are specific to Marionette. + :param timeout: Optional timeout in seconds for the server to be ready. + :returns: A dictionary of the capabilities offered. + """ + if capabilities is None: + capabilities = {"strictFileInteractability": True} + self.requested_capabilities = capabilities + + if timeout is None: + timeout = self.startup_timeout + + self.crashed = 0 + + if self.instance: + returncode = self.instance.runner.returncode + # We're managing a binary which has terminated. Start it again + # and implicitely wait for the Marionette server to be ready. + if returncode is not None: + self.start_binary(timeout) + + else: + # In the case when Marionette doesn't manage the binary wait until + # its server component has been started. + self.raise_for_port(timeout=timeout) + + self.client = transport.TcpTransport(self.host, self.port, self.socket_timeout) + self.protocol, _ = self.client.connect() + + try: + resp = self._send_message("WebDriver:NewSession", capabilities) + except errors.UnknownException: + # Force closing the managed process when the session cannot be + # created due to global JavaScript errors. + exc_type, value, tb = sys.exc_info() + if self.instance and self.instance.runner.is_running(): + self.instance.close() + reraise(exc_type, exc_type(value.message), tb) + + self.session_id = resp["sessionId"] + self.session = resp["capabilities"] + self.cleanup_ran = False + # fallback to processId can be removed in Firefox 55 + self.process_id = self.session.get( + "moz:processID", self.session.get("processId") + ) + self.profile = self.session.get("moz:profile") + + timeout = self.session.get("moz:shutdownTimeout") + if timeout is not None: + # pylint --py3k W1619 + self.shutdown_timeout = timeout / 1000 + 10 + + return self.session + + @property + def test_name(self): + return self._test_name + + @test_name.setter + def test_name(self, test_name): + self._test_name = test_name + + def delete_session(self, send_request=True): + """Close the current session and disconnect from the server. + + :param send_request: Optional, if `True` a request to close the session on + the server side will be sent. Use `False` in case of eg. in_app restart() + or quit(), which trigger a deletion themselves. Defaults to `True`. + """ + try: + if send_request: + try: + self._send_message("WebDriver:DeleteSession") + except errors.InvalidSessionIdException: + pass + finally: + self.process_id = None + self.profile = None + self.session = None + self.session_id = None + self.window = None + + if self.client is not None: + self.client.close() + + @property + def session_capabilities(self): + """A JSON dictionary representing the capabilities of the + current session. + + """ + return self.session + + @property + def current_window_handle(self): + """Get the current window's handle. + + Returns an opaque server-assigned identifier to this window + that uniquely identifies it within this Marionette instance. + This can be used to switch to this window at a later point. + + :returns: unique window handle + :rtype: string + """ + with self.using_context("content"): + self.window = self._send_message("WebDriver:GetWindowHandle", key="value") + + return self.window + + @property + def current_chrome_window_handle(self): + """Get the current chrome window's handle. Corresponds to + a chrome window that may itself contain tabs identified by + window_handles. + + Returns an opaque server-assigned identifier to this window + that uniquely identifies it within this Marionette instance. + This can be used to switch to this window at a later point. + + :returns: unique window handle + :rtype: string + """ + with self.using_context("chrome"): + self.chrome_window = self._send_message( + "WebDriver:GetWindowHandle", key="value" + ) + + return self.chrome_window + + def set_window_rect(self, x=None, y=None, height=None, width=None): + """Set the position and size of the current window. + + The supplied width and height values refer to the window outerWidth + and outerHeight values, which include scroll bars, title bars, etc. + + An error will be returned if the requested window size would result + in the window being in the maximised state. + + :param x: x coordinate for the top left of the window + :param y: y coordinate for the top left of the window + :param width: The width to resize the window to. + :param height: The height to resize the window to. + """ + if (x is None and y is None) and (height is None and width is None): + raise errors.InvalidArgumentException( + "x and y or height and width need values" + ) + + body = {"x": x, "y": y, "height": height, "width": width} + return self._send_message("WebDriver:SetWindowRect", body) + + @property + def window_rect(self): + return self._send_message("WebDriver:GetWindowRect") + + @property + def title(self): + """Current title of the active window.""" + return self._send_message("WebDriver:GetTitle", key="value") + + @property + def window_handles(self): + """Get list of windows in the current context. + + If called in the content context it will return a list of + references to all available browser windows. + + Each window handle is assigned by the server, and the list of + strings returned does not have a guaranteed ordering. + + :returns: Unordered list of unique window handles as strings + """ + with self.using_context("content"): + return self._send_message("WebDriver:GetWindowHandles") + + @property + def chrome_window_handles(self): + """Get a list of currently open chrome windows. + + Each window handle is assigned by the server, and the list of + strings returned does not have a guaranteed ordering. + + :returns: Unordered list of unique chrome window handles as strings + """ + with self.using_context("chrome"): + return self._send_message("WebDriver:GetWindowHandles") + + @property + def page_source(self): + """A string representation of the DOM.""" + return self._send_message("WebDriver:GetPageSource", key="value") + + def open(self, type=None, focus=False, private=False): + """Open a new window, or tab based on the specified context type. + + If no context type is given the application will choose the best + option based on tab and window support. + + :param type: Type of window to be opened. Can be one of "tab" or "window" + :param focus: If true, the opened window will be focused + :param private: If true, open a private window + + :returns: Dict with new window handle, and type of opened window + """ + body = {"type": type, "focus": focus, "private": private} + return self._send_message("WebDriver:NewWindow", body) + + def close(self): + """Close the current window, ending the session if it's the last + window currently open. + + :returns: Unordered list of remaining unique window handles as strings + """ + return self._send_message("WebDriver:CloseWindow") + + def close_chrome_window(self): + """Close the currently selected chrome window, ending the session + if it's the last window open. + + :returns: Unordered list of remaining unique chrome window handles as strings + """ + return self._send_message("WebDriver:CloseChromeWindow") + + def set_context(self, context): + """Sets the context that Marionette commands are running in. + + :param context: Context, may be one of the class properties + `CONTEXT_CHROME` or `CONTEXT_CONTENT`. + + Usage example:: + + marionette.set_context(marionette.CONTEXT_CHROME) + """ + if context not in [self.CONTEXT_CHROME, self.CONTEXT_CONTENT]: + raise ValueError("Unknown context: {}".format(context)) + + self._send_message("Marionette:SetContext", {"value": context}) + + @contextmanager + def using_context(self, context): + """Sets the context that Marionette commands are running in using + a `with` statement. The state of the context on the server is + saved before entering the block, and restored upon exiting it. + + :param context: Context, may be one of the class properties + `CONTEXT_CHROME` or `CONTEXT_CONTENT`. + + Usage example:: + + with marionette.using_context(marionette.CONTEXT_CHROME): + # chrome scope + ... do stuff ... + """ + scope = self._send_message("Marionette:GetContext", key="value") + self.set_context(context) + try: + yield + finally: + self.set_context(scope) + + def switch_to_alert(self): + """Returns an :class:`~marionette_driver.marionette.Alert` object for + interacting with a currently displayed alert. + + :: + + alert = self.marionette.switch_to_alert() + text = alert.text + alert.accept() + """ + return Alert(self) + + def switch_to_window(self, handle, focus=True): + """Switch to the specified window; subsequent commands will be + directed at the new window. + + :param handle: The id of the window to switch to. + + :param focus: A boolean value which determins whether to focus + the window that we just switched to. + """ + self._send_message( + "WebDriver:SwitchToWindow", {"handle": handle, "focus": focus} + ) + self.window = handle + + def switch_to_default_content(self): + """Switch the current context to page's default content.""" + return self.switch_to_frame() + + def switch_to_parent_frame(self): + """ + Switch to the Parent Frame + """ + self._send_message("WebDriver:SwitchToParentFrame") + + def switch_to_frame(self, frame=None): + """Switch the current context to the specified frame. Subsequent + commands will operate in the context of the specified frame, + if applicable. + + :param frame: A reference to the frame to switch to. This can + be an :class:`~marionette_driver.marionette.HTMLElement`, + or an integer index. If you call ``switch_to_frame`` without an + argument, it will switch to the top-level frame. + """ + body = {} + if isinstance(frame, HTMLElement): + body["element"] = frame.id + elif frame is not None: + body["id"] = frame + + self._send_message("WebDriver:SwitchToFrame", body) + + def get_url(self): + """Get a string representing the current URL. + + On Desktop this returns a string representation of the URL of + the current top level browsing context. This is equivalent to + document.location.href. + + When in the context of the chrome, this returns the canonical + URL of the current resource. + + :returns: string representation of URL + """ + return self._send_message("WebDriver:GetCurrentURL", key="value") + + def get_window_type(self): + """Gets the windowtype attribute of the window Marionette is + currently acting on. + + This command only makes sense in a chrome context. You might use this + method to distinguish a browser window from an editor window. + """ + try: + return self._send_message("Marionette:GetWindowType", key="value") + except errors.UnknownCommandException: + return self._send_message("getWindowType", key="value") + + def navigate(self, url): + """Navigate to given `url`. + + Navigates the current top-level browsing context's content + frame to the given URL and waits for the document to load or + the session's page timeout duration to elapse before returning. + + The command will return with a failure if there is an error + loading the document or the URL is blocked. This can occur if + it fails to reach the host, the URL is malformed, the page is + restricted (about:* pages), or if there is a certificate issue + to name some examples. + + The document is considered successfully loaded when the + `DOMContentLoaded` event on the frame element associated with the + `window` triggers and `document.readyState` is "complete". + + In chrome context it will change the current `window`'s location + to the supplied URL and wait until `document.readyState` equals + "complete" or the page timeout duration has elapsed. + + :param url: The URL to navigate to. + """ + self._send_message("WebDriver:Navigate", {"url": url}) + + def go_back(self): + """Causes the browser to perform a back navigation.""" + self._send_message("WebDriver:Back") + + def go_forward(self): + """Causes the browser to perform a forward navigation.""" + self._send_message("WebDriver:Forward") + + def refresh(self): + """Causes the browser to perform to refresh the current page.""" + self._send_message("WebDriver:Refresh") + + def _to_json(self, args): + if isinstance(args, list) or isinstance(args, tuple): + wrapped = [] + for arg in args: + wrapped.append(self._to_json(arg)) + elif isinstance(args, dict): + wrapped = {} + for arg in args: + wrapped[arg] = self._to_json(args[arg]) + elif type(args) == HTMLElement: + wrapped = {WEB_ELEMENT_KEY: args.id} + elif type(args) == ShadowRoot: + wrapped = {WEB_SHADOW_ROOT_KEY: args.id} + elif ( + isinstance(args, bool) + or isinstance(args, six.string_types) + or isinstance(args, int) + or isinstance(args, float) + or args is None + ): + wrapped = args + return wrapped + + def _from_json(self, value): + if isinstance(value, dict) and any( + k in value.keys() for k in HTMLElement.identifiers + ): + return HTMLElement._from_json(value, self) + elif isinstance(value, dict) and any( + k in value.keys() for k in ShadowRoot.identifiers + ): + return ShadowRoot._from_json(value, self) + elif isinstance(value, dict): + return {key: self._from_json(val) for key, val in value.items()} + elif isinstance(value, list): + return list(self._from_json(item) for item in value) + else: + return value + + def execute_script( + self, + script, + script_args=(), + new_sandbox=True, + sandbox="default", + script_timeout=None, + ): + """Executes a synchronous JavaScript script, and returns the + result (or None if the script does return a value). + + The script is executed in the context set by the most recent + :func:`set_context` call, or to the CONTEXT_CONTENT context if + :func:`set_context` has not been called. + + :param script: A string containing the JavaScript to execute. + :param script_args: An interable of arguments to pass to the script. + :param new_sandbox: If False, preserve global variables from + the last execute_*script call. This is True by default, in which + case no globals are preserved. + :param sandbox: A tag referring to the sandbox you wish to use; + if you specify a new tag, a new sandbox will be created. + If you use the special tag `system`, the sandbox will + be created using the system principal which has elevated + privileges. + :param script_timeout: Timeout in milliseconds, overriding + the session's default script timeout. + + Simple usage example: + + :: + + result = marionette.execute_script("return 1;") + assert result == 1 + + You can use the `script_args` parameter to pass arguments to the + script: + + :: + + result = marionette.execute_script("return arguments[0] + arguments[1];", + script_args=(2, 3,)) + assert result == 5 + some_element = marionette.find_element(By.ID, "someElement") + sid = marionette.execute_script("return arguments[0].id;", script_args=(some_element,)) + assert some_element.get_attribute("id") == sid + + Scripts wishing to access non-standard properties of the window + object must use window.wrappedJSObject: + + :: + + result = marionette.execute_script(''' + window.wrappedJSObject.test1 = "foo"; + window.wrappedJSObject.test2 = "bar"; + return window.wrappedJSObject.test1 + window.wrappedJSObject.test2; + ''') + assert result == "foobar" + + Global variables set by individual scripts do not persist between + script calls by default. If you wish to persist data between + script calls, you can set `new_sandbox` to False on your next call, + and add any new variables to a new 'global' object like this: + + :: + + marionette.execute_script("global.test1 = 'foo';") + result = self.marionette.execute_script("return global.test1;", new_sandbox=False) + assert result == "foo" + + """ + original_timeout = None + if script_timeout is not None: + original_timeout = self.timeout.script + self.timeout.script = script_timeout / 1000.0 + + try: + args = self._to_json(script_args) + stack = traceback.extract_stack() + frame = stack[-2:-1][0] # grab the second-to-last frame + filename = ( + frame[0] if sys.platform == "win32" else os.path.relpath(frame[0]) + ) + body = { + "script": script.strip(), + "args": args, + "newSandbox": new_sandbox, + "sandbox": sandbox, + "line": int(frame[1]), + "filename": filename, + } + rv = self._send_message("WebDriver:ExecuteScript", body, key="value") + + finally: + if script_timeout is not None: + self.timeout.script = original_timeout + + return rv + + def execute_async_script( + self, + script, + script_args=(), + new_sandbox=True, + sandbox="default", + script_timeout=None, + ): + """Executes an asynchronous JavaScript script, and returns the + result (or None if the script does return a value). + + The script is executed in the context set by the most recent + :func:`set_context` call, or to the CONTEXT_CONTENT context if + :func:`set_context` has not been called. + + :param script: A string containing the JavaScript to execute. + :param script_args: An interable of arguments to pass to the script. + :param new_sandbox: If False, preserve global variables from + the last execute_*script call. This is True by default, + in which case no globals are preserved. + :param sandbox: A tag referring to the sandbox you wish to use; if + you specify a new tag, a new sandbox will be created. If you + use the special tag `system`, the sandbox will be created + using the system principal which has elevated privileges. + :param script_timeout: Timeout in milliseconds, overriding + the session's default script timeout. + + Usage example: + + :: + + marionette.timeout.script = 10 + result = self.marionette.execute_async_script(''' + // this script waits 5 seconds, and then returns the number 1 + let [resolve] = arguments; + setTimeout(function() { + resolve(1); + }, 5000); + ''') + assert result == 1 + """ + original_timeout = None + if script_timeout is not None: + original_timeout = self.timeout.script + self.timeout.script = script_timeout / 1000.0 + + try: + args = self._to_json(script_args) + stack = traceback.extract_stack() + frame = stack[-2:-1][0] # grab the second-to-last frame + filename = ( + frame[0] if sys.platform == "win32" else os.path.relpath(frame[0]) + ) + body = { + "script": script.strip(), + "args": args, + "newSandbox": new_sandbox, + "sandbox": sandbox, + "scriptTimeout": script_timeout, + "line": int(frame[1]), + "filename": filename, + } + rv = self._send_message("WebDriver:ExecuteAsyncScript", body, key="value") + + finally: + if script_timeout is not None: + self.timeout.script = original_timeout + + return rv + + def find_element(self, method, target, id=None): + """Returns an :class:`~marionette_driver.marionette.HTMLElement` + instance that matches the specified method and target in the current + context. + + An :class:`~marionette_driver.marionette.HTMLElement` instance may be + used to call other methods on the element, such as + :func:`~marionette_driver.marionette.HTMLElement.click`. If no element + is immediately found, the attempt to locate an element will be repeated + for up to the amount of time set by + :attr:`marionette_driver.timeout.Timeouts.implicit`. If multiple + elements match the given criteria, only the first is returned. If no + element matches, a ``NoSuchElementException`` will be raised. + + :param method: The method to use to locate the element; one of: + "id", "name", "class name", "tag name", "css selector", + "link text", "partial link text" and "xpath". + Note that the "name", "link text" and "partial link test" + methods are not supported in the chrome DOM. + :param target: The target of the search. For example, if method = + "tag", target might equal "div". If method = "id", target would + be an element id. + :param id: If specified, search for elements only inside the element + with the specified id. + """ + body = {"value": target, "using": method} + if id: + body["element"] = id + + return self._send_message("WebDriver:FindElement", body, key="value") + + def find_elements(self, method, target, id=None): + """Returns a list of all + :class:`~marionette_driver.marionette.HTMLElement` instances that match + the specified method and target in the current context. + + An :class:`~marionette_driver.marionette.HTMLElement` instance may be + used to call other methods on the element, such as + :func:`~marionette_driver.marionette.HTMLElement.click`. If no element + is immediately found, the attempt to locate an element will be repeated + for up to the amount of time set by + :attr:`marionette_driver.timeout.Timeouts.implicit`. + + :param method: The method to use to locate the elements; one + of: "id", "name", "class name", "tag name", "css selector", + "link text", "partial link text" and "xpath". + Note that the "name", "link text" and "partial link test" + methods are not supported in the chrome DOM. + :param target: The target of the search. For example, if method = + "tag", target might equal "div". If method = "id", target would be + an element id. + :param id: If specified, search for elements only inside the element + with the specified id. + """ + body = {"value": target, "using": method} + if id: + body["element"] = id + + return self._send_message("WebDriver:FindElements", body) + + def get_active_element(self): + el_or_ref = self._send_message("WebDriver:GetActiveElement", key="value") + return el_or_ref + + def add_cookie(self, cookie): + """Adds a cookie to your current session. + + :param cookie: A dictionary object, with required keys - "name" + and "value"; optional keys - "path", "domain", "secure", + "expiry". + + Usage example: + + :: + + driver.add_cookie({"name": "foo", "value": "bar"}) + driver.add_cookie({"name": "foo", "value": "bar", "path": "/"}) + driver.add_cookie({"name": "foo", "value": "bar", "path": "/", + "secure": True}) + """ + self._send_message("WebDriver:AddCookie", {"cookie": cookie}) + + def delete_all_cookies(self): + """Delete all cookies in the scope of the current session. + + Usage example: + + :: + + driver.delete_all_cookies() + """ + self._send_message("WebDriver:DeleteAllCookies") + + def delete_cookie(self, name): + """Delete a cookie by its name. + + :param name: Name of cookie to delete. + + Usage example: + + :: + + driver.delete_cookie("foo") + """ + self._send_message("WebDriver:DeleteCookie", {"name": name}) + + def get_cookie(self, name): + """Get a single cookie by name. Returns the cookie if found, + None if not. + + :param name: Name of cookie to get. + """ + cookies = self.get_cookies() + for cookie in cookies: + if cookie["name"] == name: + return cookie + return None + + def get_cookies(self): + """Get all the cookies for the current domain. + + This is the equivalent of calling `document.cookie` and + parsing the result. + + :returns: A list of cookies for the current domain. + """ + return self._send_message("WebDriver:GetCookies") + + def save_screenshot(self, fh, element=None, full=True, scroll=True): + """Takes a screenhot of a web element or the current frame and + saves it in the filehandle. + + It is a wrapper around screenshot() + :param fh: The filehandle to save the screenshot at. + + The rest of the parameters are defined like in screenshot() + """ + data = self.screenshot(element, "binary", full, scroll) + fh.write(data) + + def screenshot(self, element=None, format="base64", full=True, scroll=True): + """Takes a screenshot of a web element or the current frame. + + The screen capture is returned as a lossless PNG image encoded + as a base 64 string by default. If the `element` argument is defined the + capture area will be limited to the bounding box of that + element. Otherwise, the capture area will be the bounding box + of the current frame. + + :param element: The element to take a screenshot of. If None, will + take a screenshot of the current frame. + + :param format: if "base64" (the default), returns the screenshot + as a base64-string. If "binary", the data is decoded and + returned as raw binary. If "hash", the data is hashed using + the SHA-256 algorithm and the result is returned as a hex digest. + + :param full: If True (the default), the capture area will be the + complete frame. Else only the viewport is captured. Only applies + when `element` is None. + + :param scroll: When `element` is provided, scroll to it before + taking the screenshot (default). Otherwise, avoid scrolling + `element` into view. + """ + + if element: + element = element.id + + body = {"id": element, "full": full, "hash": False, "scroll": scroll} + if format == "hash": + body["hash"] = True + + data = self._send_message("WebDriver:TakeScreenshot", body, key="value") + + if format == "base64" or format == "hash": + return data + elif format == "binary": + return base64.b64decode(data.encode("ascii")) + else: + raise ValueError( + "format parameter must be either 'base64'" + " or 'binary', not {0}".format(repr(format)) + ) + + @property + def orientation(self): + """Get the current browser orientation. + + Will return one of the valid primary orientation values + portrait-primary, landscape-primary, portrait-secondary, or + landscape-secondary. + """ + try: + return self._send_message("Marionette:GetScreenOrientation", key="value") + except errors.UnknownCommandException: + return self._send_message("getScreenOrientation", key="value") + + def set_orientation(self, orientation): + """Set the current browser orientation. + + The supplied orientation should be given as one of the valid + orientation values. If the orientation is unknown, an error + will be raised. + + Valid orientations are "portrait" and "landscape", which fall + back to "portrait-primary" and "landscape-primary" + respectively, and "portrait-secondary" as well as + "landscape-secondary". + + :param orientation: The orientation to lock the screen in. + """ + body = {"orientation": orientation} + try: + self._send_message("Marionette:SetScreenOrientation", body) + except errors.UnknownCommandException: + self._send_message("setScreenOrientation", body) + + def minimize_window(self): + """Iconify the browser window currently receiving commands. + The action should be equivalent to the user pressing the minimize + button in the OS window. + + Note that this command is not available on Fennec. It may also + not be available in certain window managers. + + :returns Window rect. + """ + return self._send_message("WebDriver:MinimizeWindow") + + def maximize_window(self): + """Resize the browser window currently receiving commands. + The action should be equivalent to the user pressing the maximize + button in the OS window. + + + Note that this command is not available on Fennec. It may also + not be available in certain window managers. + + :returns: Window rect. + """ + return self._send_message("WebDriver:MaximizeWindow") + + def fullscreen(self): + """Synchronously sets the user agent window to full screen as + if the user had done "View > Enter Full Screen", or restores + it if it is already in full screen. + + :returns: Window rect. + """ + return self._send_message("WebDriver:FullscreenWindow") diff --git a/testing/marionette/client/marionette_driver/timeout.py b/testing/marionette/client/marionette_driver/timeout.py new file mode 100644 index 0000000000..27848d0121 --- /dev/null +++ b/testing/marionette/client/marionette_driver/timeout.py @@ -0,0 +1,103 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from . import errors + +DEFAULT_SCRIPT_TIMEOUT = 30 +DEFAULT_PAGE_LOAD_TIMEOUT = 300 +DEFAULT_IMPLICIT_WAIT_TIMEOUT = 0 + + +class Timeouts(object): + """Manage timeout settings in the Marionette session. + + Usage:: + + marionette = Marionette(...) + marionette.start_session() + marionette.timeout.page_load = 10 + marionette.timeout.page_load + # => 10 + + """ + + def __init__(self, marionette): + self._marionette = marionette + + def _set(self, name, sec): + ms = sec * 1000 + self._marionette._send_message("WebDriver:SetTimeouts", {name: ms}) + + def _get(self, name): + ts = self._marionette._send_message("WebDriver:GetTimeouts") + if name not in ts: + raise KeyError() + ms = ts[name] + return ms / 1000.0 + + @property + def script(self): + """Get the session's script timeout. This specifies the time + to wait for injected scripts to finished before interrupting + them. It is by default 30 seconds. + + """ + return self._get("script") + + @script.setter + def script(self, sec): + """Set the session's script timeout. This specifies the time + to wait for injected scripts to finish before interrupting them. + + """ + self._set("script", sec) + + @property + def page_load(self): + """Get the session's page load timeout. This specifies the time + to wait for the page loading to complete. It is by default 5 + minutes (or 300 seconds). + + """ + # remove fallback when Firefox 56 is stable + try: + return self._get("pageLoad") + except KeyError: + return self._get("page load") + + @page_load.setter + def page_load(self, sec): + """Set the session's page load timeout. This specifies the time + to wait for the page loading to complete. + + """ + # remove fallback when Firefox 56 is stable + try: + self._set("pageLoad", sec) + except errors.InvalidArgumentException: + return self._set("page load", sec) + + @property + def implicit(self): + """Get the session's implicit wait timeout. This specifies the + time to wait for the implicit element location strategy when + retrieving elements. It is by default disabled (0 seconds). + + """ + return self._get("implicit") + + @implicit.setter + def implicit(self, sec): + """Set the session's implicit wait timeout. This specifies the + time to wait for the implicit element location strategy when + retrieving elements. + + """ + self._set("implicit", sec) + + def reset(self): + """Resets timeouts to their default values.""" + self.script = DEFAULT_SCRIPT_TIMEOUT + self.page_load = DEFAULT_PAGE_LOAD_TIMEOUT + self.implicit = DEFAULT_IMPLICIT_WAIT_TIMEOUT diff --git a/testing/marionette/client/marionette_driver/transport.py b/testing/marionette/client/marionette_driver/transport.py new file mode 100644 index 0000000000..58b39df47b --- /dev/null +++ b/testing/marionette/client/marionette_driver/transport.py @@ -0,0 +1,408 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import json +import socket +import sys +import time +from threading import RLock + +import six + + +class SocketTimeout(object): + def __init__(self, socket_ctx, timeout): + self.socket_ctx = socket_ctx + self.timeout = timeout + self.old_timeout = None + + def __enter__(self): + self.old_timeout = self.socket_ctx.socket_timeout + self.socket_ctx.socket_timeout = self.timeout + + def __exit__(self, *args, **kwargs): + self.socket_ctx.socket_timeout = self.old_timeout + + +class Message(object): + def __init__(self, msgid): + self.id = msgid + + def __eq__(self, other): + return self.id == other.id + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + # pylint --py3k: W1641 + return hash(self.id) + + +class Command(Message): + TYPE = 0 + + def __init__(self, msgid, name, params): + Message.__init__(self, msgid) + self.name = name + self.params = params + + def __str__(self): + return "<Command id={0}, name={1}, params={2}>".format( + self.id, self.name, self.params + ) + + def to_msg(self): + msg = [Command.TYPE, self.id, self.name, self.params] + return json.dumps(msg) + + @staticmethod + def from_msg(data): + assert data[0] == Command.TYPE + cmd = Command(data[1], data[2], data[3]) + return cmd + + +class Response(Message): + TYPE = 1 + + def __init__(self, msgid, error, result): + Message.__init__(self, msgid) + self.error = error + self.result = result + + def __str__(self): + return "<Response id={0}, error={1}, result={2}>".format( + self.id, self.error, self.result + ) + + def to_msg(self): + msg = [Response.TYPE, self.id, self.error, self.result] + return json.dumps(msg) + + @staticmethod + def from_msg(data): + assert data[0] == Response.TYPE + return Response(data[1], data[2], data[3]) + + +class SocketContext(object): + """Object that guards access to a socket via a lock. + + The socket must be accessed using this object as a context manager; + access to the socket outside of a context will bypass the lock.""" + + def __init__(self, host, port, timeout): + self.lock = RLock() + + self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock.settimeout(timeout) + self._sock.connect((host, port)) + + @property + def socket_timeout(self): + return self._sock.gettimeout() + + @socket_timeout.setter + def socket_timeout(self, value): + self._sock.settimeout(value) + + def __enter__(self): + self.lock.acquire() + return self._sock + + def __exit__(self, *args, **kwargs): + self.lock.release() + + +class TcpTransport(object): + """Socket client that communciates with Marionette via TCP. + + It speaks the protocol of the remote debugger in Gecko, in which + messages are always preceded by the message length and a colon, e.g.: + + 7:MESSAGE + + On top of this protocol it uses a Marionette message format, that + depending on the protocol level offered by the remote server, varies. + Supported protocol levels are `min_protocol_level` and above. + """ + + max_packet_length = 4096 + min_protocol_level = 3 + + def __init__(self, host, port, socket_timeout=60.0): + """If `socket_timeout` is `0` or `0.0`, non-blocking socket mode + will be used. Setting it to `1` or `None` disables timeouts on + socket operations altogether. + """ + self._socket_context = None + + self.host = host + self.port = port + self._socket_timeout = socket_timeout + + self.protocol = self.min_protocol_level + self.application_type = None + self.last_id = 0 + self.expected_response = None + + @property + def socket_timeout(self): + return self._socket_timeout + + @socket_timeout.setter + def socket_timeout(self, value): + self._socket_timeout = value + + if self._socket_context is not None: + self._socket_context.socket_timeout = value + + def _unmarshal(self, packet): + """Convert data from bytes to a Message subtype + + Message format is [type, msg_id, body1, body2], where body1 and body2 depend + on the message type. + + :param packet: Bytes received over the wire representing a complete message. + """ + msg = None + + data = json.loads(packet) + msg_type = data[0] + + if msg_type == Command.TYPE: + msg = Command.from_msg(data) + elif msg_type == Response.TYPE: + msg = Response.from_msg(data) + else: + raise ValueError("Invalid message body {!r}".format(packet)) + + return msg + + def receive(self, unmarshal=True): + """Wait for the next complete response from the remote. + + Packet format is length-prefixed JSON: + + packet = digit+ ":" body + digit = "0"-"9" + body = JSON text + + :param unmarshal: Default is to deserialise the packet and + return a ``Message`` type. Setting this to false will return + the raw packet. + """ + # Initally we read 4 bytes. We don't support reading beyond the end of a message, and + # so assuming the JSON body has to be an array or object, the minimum possible message + # is 4 bytes: "2:{}". In practice the marionette format has some required fields so the + # message is longer, but 4 bytes allows reading messages with bodies up to 999 bytes in + # length in two reads, which is the common case. + with self._socket_context as sock: + recv_bytes = 4 + + length_prefix = b"" + + body_length = -1 + body_received = 0 + body_parts = [] + + now = time.time() + timeout_time = ( + now + self.socket_timeout if self.socket_timeout is not None else None + ) + + while recv_bytes > 0: + if timeout_time is not None and time.time() > timeout_time: + raise socket.timeout( + "Connection timed out after {}s".format(self.socket_timeout) + ) + + try: + chunk = sock.recv(recv_bytes) + except OSError: + continue + + if not chunk: + raise socket.error("No data received over socket") + + body_part = None + if body_length > 0: + body_part = chunk + else: + parts = chunk.split(b":", 1) + length_prefix += parts[0] + + # With > 10 decimal digits we aren't going to have a 32 bit number + if len(length_prefix) > 10: + raise ValueError( + "Invalid message length: {!r}".format(length_prefix) + ) + + if len(parts) == 2: + # We found a : so we know the full length + err = None + try: + body_length = int(length_prefix) + except ValueError: + err = "expected an integer" + else: + if body_length <= 0: + err = "expected a positive integer" + elif body_length > 2 ** 32 - 1: + err = "expected a 32 bit integer" + if err is not None: + raise ValueError( + "Invalid message length: {} got {!r}".format( + err, length_prefix + ) + ) + body_part = parts[1] + + # If we didn't find a : yet we keep reading 4 bytes at a time until we do. + # We could increase this here to 7 bytes (since we can't have more than 10 + # length bytes and a seperator byte), or just increase it to + # int(length_prefix) + 1 since that's the minimum total number of remaining + # bytes (if the : is in the next byte), but it's probably not worth optimising + # for large messages. + + if body_part is not None: + body_received += len(body_part) + body_parts.append(body_part) + recv_bytes = body_length - body_received + + body = b"".join(body_parts) + if unmarshal: + msg = self._unmarshal(body) + self.last_id = msg.id + + # keep reading incoming responses until + # we receive the user's expected response + if isinstance(msg, Response) and msg != self.expected_response: + return self.receive(unmarshal) + + return msg + return body + + def connect(self): + """Connect to the server and process the hello message we expect + to receive in response. + + Returns a tuple of the protocol level and the application type. + """ + try: + self._socket_context = SocketContext( + self.host, self.port, self._socket_timeout + ) + except Exception: + # Unset so that the next attempt to send will cause + # another connection attempt. + self._socket_context = None + raise + + try: + with SocketTimeout(self._socket_context, 60.0): + # first packet is always a JSON Object + # which we can use to tell which protocol level we are at + raw = self.receive(unmarshal=False) + except socket.timeout: + exc_cls, exc, tb = sys.exc_info() + msg = "Connection attempt failed because no data has been received over the socket: {}" + six.reraise(exc_cls, exc_cls(msg.format(exc)), tb) + + hello = json.loads(raw) + application_type = hello.get("applicationType") + protocol = hello.get("marionetteProtocol") + + if application_type != "gecko": + raise ValueError( + "Application type '{}' is not supported".format(application_type) + ) + + if not isinstance(protocol, int) or protocol < self.min_protocol_level: + msg = "Earliest supported protocol level is '{}' but got '{}'" + raise ValueError(msg.format(self.min_protocol_level, protocol)) + + self.application_type = application_type + self.protocol = protocol + + return (self.protocol, self.application_type) + + def send(self, obj): + """Send message to the remote server. Allowed input is a + ``Message`` instance or a JSON serialisable object. + """ + if not self._socket_context: + self.connect() + + if isinstance(obj, Message): + data = obj.to_msg() + if isinstance(obj, Command): + self.expected_response = obj + else: + data = json.dumps(obj) + data = six.ensure_binary(data) + payload = six.ensure_binary(str(len(data))) + b":" + data + + with self._socket_context as sock: + totalsent = 0 + while totalsent < len(payload): + sent = sock.send(payload[totalsent:]) + if sent == 0: + raise IOError( + "Socket error after sending {0} of {1} bytes".format( + totalsent, len(payload) + ) + ) + else: + totalsent += sent + + def respond(self, obj): + """Send a response to a command. This can be an arbitrary JSON + serialisable object or an ``Exception``. + """ + res, err = None, None + if isinstance(obj, Exception): + err = obj + else: + res = obj + msg = Response(self.last_id, err, res) + self.send(msg) + return self.receive() + + def request(self, name, params): + """Sends a message to the remote server and waits for a response + to come back. + """ + self.last_id = self.last_id + 1 + cmd = Command(self.last_id, name, params) + self.send(cmd) + return self.receive() + + def close(self): + """Close the socket. + + First forces the socket to not send data anymore, and then explicitly + close it to free up its resources. + + See: https://docs.python.org/2/howto/sockets.html#disconnecting + """ + if self._socket_context: + with self._socket_context as sock: + try: + sock.shutdown(socket.SHUT_RDWR) + except IOError as exc: + # If the socket is already closed, don't care about: + # Errno 57: Socket not connected + # Errno 107: Transport endpoint is not connected + if exc.errno not in (57, 107): + raise + + if sock: + # Guard against unclean shutdown. + sock.close() + self._socket_context = None + + def __del__(self): + self.close() diff --git a/testing/marionette/client/marionette_driver/wait.py b/testing/marionette/client/marionette_driver/wait.py new file mode 100644 index 0000000000..bc34ccf4cb --- /dev/null +++ b/testing/marionette/client/marionette_driver/wait.py @@ -0,0 +1,175 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import collections +import sys +import time + +from . import errors + +DEFAULT_TIMEOUT = 5 +DEFAULT_INTERVAL = 0.1 + + +class Wait(object): + + """An explicit conditional utility class for waiting until a condition + evaluates to true or not null. + + This will repeatedly evaluate a condition in anticipation for a + truthy return value, or its timeout to expire, or its waiting + predicate to become true. + + A `Wait` instance defines the maximum amount of time to wait for a + condition, as well as the frequency with which to check the + condition. Furthermore, the user may configure the wait to ignore + specific types of exceptions whilst waiting, such as + `errors.NoSuchElementException` when searching for an element on + the page. + + """ + + def __init__( + self, + marionette, + timeout=None, + interval=None, + ignored_exceptions=None, + clock=None, + ): + """Configure the Wait instance to have a custom timeout, interval, and + list of ignored exceptions. Optionally a different time + implementation than the one provided by the standard library + (time) can also be provided. + + Sample usage:: + + # Wait 30 seconds for window to open, checking for its presence once + # every 5 seconds. + wait = Wait(marionette, timeout=30, interval=5, + ignored_exceptions=errors.NoSuchWindowException) + window = wait.until(lambda m: m.switch_to_window(42)) + + :param marionette: The input value to be provided to + conditions, usually a Marionette instance. + + :param timeout: How long to wait for the evaluated condition + to become true. The default timeout is + `wait.DEFAULT_TIMEOUT`. + + :param interval: How often the condition should be evaluated. + In reality the interval may be greater as the cost of + evaluating the condition function. If that is not the case the + interval for the next condition function call is shortend to keep + the original interval sequence as best as possible. + The default polling interval is `wait.DEFAULT_INTERVAL`. + + :param ignored_exceptions: Ignore specific types of exceptions + whilst waiting for the condition. Any exceptions not + whitelisted will be allowed to propagate, terminating the + wait. + + :param clock: Allows overriding the use of the runtime's + default time library. See `wait.SystemClock` for + implementation details. + + """ + + self.marionette = marionette + self.timeout = timeout if timeout is not None else DEFAULT_TIMEOUT + self.interval = interval if interval is not None else DEFAULT_INTERVAL + self.clock = clock or SystemClock() + self.end = self.clock.now + self.timeout + + exceptions = [] + if ignored_exceptions is not None: + if isinstance(ignored_exceptions, collections.abc.Iterable): + exceptions.extend(iter(ignored_exceptions)) + else: + exceptions.append(ignored_exceptions) + self.exceptions = tuple(set(exceptions)) + + def until(self, condition, is_true=None, message=""): + """Repeatedly runs condition until its return value evaluates to true, + or its timeout expires or the predicate evaluates to true. + + This will poll at the given interval until the given timeout + is reached, or the predicate or conditions returns true. A + condition that returns null or does not evaluate to true will + fully elapse its timeout before raising an + `errors.TimeoutException`. + + If an exception is raised in the condition function and it's + not ignored, this function will raise immediately. If the + exception is ignored, it will continue polling for the + condition until it returns successfully or a + `TimeoutException` is raised. + + :param condition: A callable function whose return value will + be returned by this function if it evaluates to true. + + :param is_true: An optional predicate that will terminate and + return when it evaluates to False. It should be a + function that will be passed clock and an end time. The + default predicate will terminate a wait when the clock + elapses the timeout. + + :param message: An optional message to include in the + exception's message if this function times out. + + """ + + rv = None + last_exc = None + until = is_true or until_pred + start = self.clock.now + + while not until(self.clock, self.end): + try: + next = self.clock.now + self.interval + rv = condition(self.marionette) + except (KeyboardInterrupt, SystemExit): + raise + except self.exceptions: + last_exc = sys.exc_info() + + # Re-adjust the interval depending on how long the callback + # took to evaluate the condition + interval_new = max(next - self.clock.now, 0) + + if not rv: + self.clock.sleep(interval_new) + continue + + if rv is not None: + return rv + + self.clock.sleep(interval_new) + + if message: + message = " with message: {}".format(message) + + raise errors.TimeoutException( + # pylint: disable=W1633 + "Timed out after {0:.1f} seconds{1}".format( + float(round((self.clock.now - start), 1)), message if message else "" + ), + cause=last_exc, + ) + + +def until_pred(clock, end): + return clock.now >= end + + +class SystemClock(object): + def __init__(self): + self._time = time + + def sleep(self, duration): + self._time.sleep(duration) + + @property + def now(self): + return self._time.time() diff --git a/testing/marionette/client/requirements.txt b/testing/marionette/client/requirements.txt new file mode 100644 index 0000000000..220531c4f5 --- /dev/null +++ b/testing/marionette/client/requirements.txt @@ -0,0 +1,3 @@ +mozrunner >= 7.4.0 +mozversion >= 2.1.0 +six diff --git a/testing/marionette/client/setup.py b/testing/marionette/client/setup.py new file mode 100644 index 0000000000..02913326b2 --- /dev/null +++ b/testing/marionette/client/setup.py @@ -0,0 +1,52 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import os +import re + +from setuptools import find_packages, setup + +THIS_DIR = os.path.dirname(os.path.realpath(__name__)) + + +def read(*parts): + with open(os.path.join(THIS_DIR, *parts)) as f: + return f.read() + + +def get_version(): + return re.findall( + '__version__ = "([\d\.]+)"', read("marionette_driver", "__init__.py"), re.M + )[0] + + +setup( + name="marionette_driver", + version=get_version(), + description="Marionette Driver", + long_description="See https://firefox-source-docs.mozilla.org/python/marionette_driver.html", + # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Topic :: Software Development :: Quality Assurance", + "Topic :: Software Development :: Testing", + "Topic :: Utilities", + "Programming Language :: Python", + "Programming Language :: Python :: 2.7", + ], + keywords="mozilla", + author="Auto-tools", + author_email="dev-webdriver@mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Marionette", + license="MPL", + packages=find_packages(), + include_package_data=True, + zip_safe=False, + install_requires=read("requirements.txt").splitlines(), +) |