diff options
Diffstat (limited to 'testing/marionette/client')
32 files changed, 5649 insertions, 0 deletions
diff --git a/testing/marionette/client/.flake8 b/testing/marionette/client/.flake8 new file mode 100644 index 000000000..5f607bc63 --- /dev/null +++ b/testing/marionette/client/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 99 +exclude = __init__.py,disti/*,build/*, diff --git a/testing/marionette/client/MANIFEST.in b/testing/marionette/client/MANIFEST.in new file mode 100644 index 000000000..cf628b039 --- /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 000000000..f3d89d6d4 --- /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 000000000..294855a6f --- /dev/null +++ b/testing/marionette/client/docs/advanced/actions.rst @@ -0,0 +1,46 @@ +Actions +======= + +.. py:currentmodule:: marionette + +Action Sequences +---------------- + +:class:`Actions` are designed as a way to simulate user input as closely as possible +on a touch device like a smart phone. A common operation is to tap the screen +and drag your finger to another part of the screen and lift it off. + +This can be simulated using an Action:: + + from marionette import Actions + + start_element = marionette.find_element('id', 'start') + end_element = marionette.find_element('id', 'end') + + action = Actions(marionette) + action.press(start_element).wait(1).move(end_element).release() + action.perform() + +This will simulate pressing an element, waiting for one second, moving the +finger over to another element and then lifting the finger off the screen. The +wait is optional in this case, but can be useful for simulating delays typical +to a users behaviour. + +Multi-Action Sequences +---------------------- + +Sometimes it may be necessary to simulate multiple actions at the same time. +For example a user may be dragging one finger while tapping another. This is +where :class:`MultiActions` come in. MultiActions are simply a way of combining +two or more actions together and performing them all at the same time:: + + action1 = Actions(marionette) + action1.press(start_element).move(end_element).release() + + action2 = Actions(marionette) + action2.press(another_element).wait(1).release() + + multi = MultiActions(marionette) + multi.add(action1) + multi.add(action2) + multi.perform() diff --git a/testing/marionette/client/docs/advanced/debug.rst b/testing/marionette/client/docs/advanced/debug.rst new file mode 100644 index 000000000..e72d2549b --- /dev/null +++ b/testing/marionette/client/docs/advanced/debug.rst @@ -0,0 +1,54 @@ +Debugging +========= + +.. py:currentmodule:: 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 + + +Storing Logs on the Server +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +By calling `~Marionette.log` it is possible to store a message on the server. +Logs can later be retrieved using `~Marionette.get_logs`. For example:: + + try: + marionette.log("Sending a click event") # logged at INFO level + elem.click() + except: + marionette.log("Something went wrong!", "ERROR") + + print(marionette.get_logs()) + +Disclaimer: Example for illustrative purposes only, don't actually hide +tracebacks like that! + + +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 `~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 +`~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 000000000..abcbc8e89 --- /dev/null +++ b/testing/marionette/client/docs/advanced/findelement.rst @@ -0,0 +1,126 @@ +Finding Elements +================ +.. py:currentmodule:: 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 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') + + +Finding Anonymous Nodes +----------------------- + +When working in chrome scope, for example manipulating the Firefox user +interface, you may run into something called an anonymous node. + +Firefox uses a markup language called XUL_ for its interface. XUL is similar +to HTML in that it has a DOM and tags that render controls on the display. One +ability of XUL is to create re-useable widgets that are made up out of several +smaller XUL elements. These widgets can be bound to the DOM using something +called the `XML binding language (XBL)`_. + +The end result is that the DOM sees the widget as a single entity. It doesn't +know anything about how that widget is made up. All of the smaller XUL elements +that make up the widget are called `anonymous content`_. It is not possible to +query such elements using traditional DOM methods like `getElementById`. + +Marionette provides two special strategies used for finding anonymous content. +Unlike normal elements, anonymous nodes can only be seen by their parent. So +it's necessary to first find the parent element and then search for the +anonymous children from there. + +* `anon` - Finds all anonymous children of the element, there is no search term + so `None` must be passed in:: + + anon_children = client.find_element('id', 'parent').find_elements('anon', None) + +* `anon attribute` - Find an anonymous child based on an attribute. An + unofficial convention is for anonymous nodes to have an + `anonid` attribute:: + + anon_child = client.find_element('id', 'parent').find_element('anon attribute', {'anonid': 'container'}) + + +.. _XUL: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL +.. _XML binding language (XBL): https://developer.mozilla.org/en-US/docs/XBL +.. _anonymous content: https://developer.mozilla.org/en-US/docs/XBL/XBL_1.0_Reference/Anonymous_Content diff --git a/testing/marionette/client/docs/advanced/landing.rst b/testing/marionette/client/docs/advanced/landing.rst new file mode 100644 index 000000000..0a44de63d --- /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 000000000..0af576865 --- /dev/null +++ b/testing/marionette/client/docs/advanced/stale.rst @@ -0,0 +1,71 @@ +Dealing with Stale Elements +=========================== +.. py:currentmodule:: 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 +---------------------------------------- + +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:: + + 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:: + + 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.expected`. diff --git a/testing/marionette/client/docs/basics.rst b/testing/marionette/client/docs/basics.rst new file mode 100644 index 000000000..0de72c625 --- /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('localhost', 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) + assert iframe == client.get_active_frame() + +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 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 a special +`marionetteScriptFinished()` function is called: + +.. parsed-literal:: + result = client.execute_async_script(""" + setTimeout(function() { + marionetteScriptFinished("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 000000000..c3a74eef6 --- /dev/null +++ b/testing/marionette/client/docs/conf.py @@ -0,0 +1,259 @@ +# -*- 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 = True + +# 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 000000000..b1f266726 --- /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 000000000..e6d755613 --- /dev/null +++ b/testing/marionette/client/docs/interactive.rst @@ -0,0 +1,55 @@ +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 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='localhost', 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.execute_script("alert('o hai there!');") + +You should now see this alert pop up! How exciting! Okay, let's do +something practical. Close the dialog and try this: + +.. 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 even find an element and click on it. Let's say you want to get +the first link: + +.. parsed-literal:: + from marionette 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 000000000..fb02fc1a8 --- /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 000000000..7d2fc5b00 --- /dev/null +++ b/testing/marionette/client/docs/reference.rst @@ -0,0 +1,51 @@ +============= +API Reference +============= + +Marionette +---------- +.. autoclass:: marionette_driver.marionette.Marionette + :members: + +HTMLElement +----------- +.. autoclass:: marionette_driver.marionette.HTMLElement + :members: + +DateTimeValue +------------- +.. autoclass:: marionette_driver.DateTimeValue + :members: + +Actions +------- +.. autoclass:: marionette_driver.marionette.Actions + :members: + +MultiActions +------------ +.. autoclass:: marionette_driver.marionette.MultiActions + :members: + +Wait +---- +.. autoclass:: marionette_driver.Wait + :members: + :special-members: +.. autoattribute marionette_driver.wait.DEFAULT_TIMEOUT +.. autoattribute marionette_driver.wait.DEFAULT_INTERVAL + +Built-in Conditions +^^^^^^^^^^^^^^^^^^^ +.. automodule:: marionette_driver.expected + :members: + +Addons +------ +.. autoclass:: marionette_driver.addons.Addons + :members: + +Localization +------------ +.. 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 000000000..d947d9c27 --- /dev/null +++ b/testing/marionette/client/marionette_driver/__init__.py @@ -0,0 +1,26 @@ +# 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__ = '2.2.0' + +from marionette_driver import ( + addons, + by, + date_time_value, + decorators, + errors, + expected, + geckoinstance, + gestures, + keys, + localization, + marionette, + selection, + wait, +) +from marionette_driver.by import By +from marionette_driver.date_time_value import DateTimeValue +from marionette_driver.gestures import smooth_scroll, pinch +from marionette_driver.marionette import Actions +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 000000000..0a8baef4f --- /dev/null +++ b/testing/marionette/client/marionette_driver/addons.py @@ -0,0 +1,70 @@ +# 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 + +__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` + + """ + 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. + + """ + body = {"id": addon_id} + self._mn._send_message("addon:uninstall", body) diff --git a/testing/marionette/client/marionette_driver/by.py b/testing/marionette/client/marionette_driver/by.py new file mode 100644 index 000000000..8232be145 --- /dev/null +++ b/testing/marionette/client/marionette_driver/by.py @@ -0,0 +1,27 @@ +# Copyright 2008-2009 WebDriver committers +# Copyright 2008-2009 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +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" + ANON_ATTRIBUTE = "anon attribute" + ANON = "anon" 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 000000000..35c541dc0 --- /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 000000000..f65152471 --- /dev/null +++ b/testing/marionette/client/marionette_driver/decorators.py @@ -0,0 +1,69 @@ +# 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 functools import wraps +import socket + + +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): + # In case of socket failures which will also include crashes of the + # application, make sure to handle those correctly. + m = _find_marionette_in_args(*args, **kwargs) + 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 000000000..8fb1d564e --- /dev/null +++ b/testing/marionette/client/marionette_driver/errors.py @@ -0,0 +1,179 @@ +# 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 + + +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 + + super(MarionetteException, self).__init__(message) + + def __str__(self): + msg = str(self.message) + tb = None + + if self.cause: + if type(self.cause) is tuple: + msg += ", caused by {0!r}".format(self.cause[0]) + tb = self.cause[2] + else: + msg += ", caused by {}".format(self.cause) + if self.stacktrace: + st = "".join(["\t{}\n".format(x) + for x in self.stacktrace.splitlines()]) + msg += "\nstacktrace:\n{}".format(st) + + if tb: + msg += ': ' + "".join(traceback.format_tb(tb)) + + return msg + + +class ElementNotSelectableException(MarionetteException): + status = "element not selectable" + + +class ElementClickInterceptedException(MarionetteException): + status = "element click intercepted" + + +class InsecureCertificateException(MarionetteException): + status = "insecure certificate" + + +class InvalidArgumentException(MarionetteException): + status = "invalid argument" + + +class InvalidSessionIdException(MarionetteException): + status = "invalid session id" + + +class TimeoutException(MarionetteException): + status = "timeout" + + +class JavascriptException(MarionetteException): + status = "javascript error" + + +class NoSuchElementException(MarionetteException): + status = "no such element" + + +class NoSuchWindowException(MarionetteException): + status = "no such window" + + +class StaleElementException(MarionetteException): + status = "stale element reference" + + +class ScriptTimeoutException(MarionetteException): + status = "script timeout" + + +class ElementNotVisibleException(MarionetteException): + """Deprecated. Will be removed with the release of Firefox 54.""" + + status = "element not visible" + + def __init__(self, + message="Element is not currently visible and may not be manipulated", + stacktrace=None, cause=None): + super(ElementNotVisibleException, self).__init__( + message, cause=cause, stacktrace=stacktrace) + + +class ElementNotAccessibleException(MarionetteException): + status = "element not accessible" + + +class ElementNotInteractableException(MarionetteException): + status = "element not interactable" + + +class NoSuchFrameException(MarionetteException): + status = "no such frame" + + +class InvalidElementStateException(MarionetteException): + status = "invalid element state" + + +class NoAlertPresentException(MarionetteException): + status = "no such alert" + + +class InvalidCookieDomainException(MarionetteException): + status = "invalid cookie domain" + + +class UnableToSetCookieException(MarionetteException): + status = "unable to set cookie" + + +class InvalidElementCoordinates(MarionetteException): + status = "invalid element coordinates" + + +class InvalidSelectorException(MarionetteException): + status = "invalid selector" + + +class MoveTargetOutOfBoundsException(MarionetteException): + status = "move target out of bounds" + + +class SessionNotCreatedException(MarionetteException): + status = "session not created" + + +class UnexpectedAlertOpen(MarionetteException): + status = "unexpected alert open" + + +class UnknownCommandException(MarionetteException): + status = "unknown command" + + +class UnknownException(MarionetteException): + status = "unknown error" + + +class UnsupportedOperationException(MarionetteException): + status = "unsupported operation" + + +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 000000000..d8c47e708 --- /dev/null +++ b/testing/marionette/client/marionette_driver/expected.py @@ -0,0 +1,311 @@ +# 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 errors +import types + +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: + 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 `is_displayed()` on an `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 `is_displayed()` on an `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 000000000..7e2048187 --- /dev/null +++ b/testing/marionette/client/marionette_driver/geckoinstance.py @@ -0,0 +1,467 @@ +# 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 sys +import tempfile +import time + +from copy import deepcopy + +import mozversion + +from mozprofile import Profile +from mozrunner import Runner, FennecEmulatorRunner + + +class GeckoInstance(object): + required_prefs = { + # 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, + + # Do not send Firefox health reports to the production server + "datareporting.healthreport.documentServerURI": "http://%(server)s/dummy/healthreport/", + "datareporting.healthreport.about.reportUrl": "http://%(server)s/dummy/abouthealthreport/", + + # Do not show datareporting policy notifications which can interfer with tests + "datareporting.policy.dataSubmissionPolicyBypassNotification": True, + + # Until Bug 1238095 is fixed, we have to enable CPOWs in order + # for Marionette tests to work properly. + "dom.ipc.cpows.forbid-unsafe-from-browser": False, + "dom.ipc.reportProcessHangs": False, + + # No slow script dialogs + "dom.max_chrome_script_run_time": 0, + "dom.max_script_run_time": 0, + + # Only load extensions from the application and user profile + # AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION + "extensions.autoDisableScopes": 0, + "extensions.enabledScopes": 5, + # don't block add-ons for e10s + "extensions.e10sBlocksEnabling": False, + # Disable metadata caching for installed add-ons by default + "extensions.getAddons.cache.enabled": False, + # Disable intalling any distribution add-ons + "extensions.installDistroAddons": False, + "extensions.showMismatchUI": False, + # Turn off extension updates so they don't bother tests + "extensions.update.enabled": False, + "extensions.update.notifyUser": False, + # Make sure opening about:addons won"t hit the network + "extensions.webservice.discoverURL": "http://%(server)s/dummy/discoveryURL", + + # Allow the application to have focus even it runs in the background + "focusmanager.testmode": True, + + # Disable useragent updates + "general.useragent.updates.enabled": False, + + # Always use network provider for geolocation tests + # so we bypass the OSX dialog raised by the corelocation provider + "geo.provider.testing": True, + # Do not scan Wifi + "geo.wifi.scan": False, + + # No hang monitor + "hangmonitor.timeout": 0, + + "javascript.options.showInConsole": True, + "marionette.defaultPrefs.enabled": True, + "media.volume_scale": "0.01", + + # Make sure the disk cache doesn't get auto disabled + "network.http.bypass-cachelock-threshold": 200000, + # Do not prompt for temporary redirects + "network.http.prompt-temp-redirect": False, + # Disable speculative connections so they aren"t reported as leaking when they"re + # hanging around + "network.http.speculative-parallel-limit": 0, + # 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", + + # Tests don't wait for the notification button security delay + "security.notification_enable_delay": 0, + + # Ensure blocklist updates don't hit the network + "services.settings.server": "http://%(server)s/dummy/blocklist/", + + # Disable password capture, so that tests that include forms aren"t + # influenced by the presence of the persistent doorhanger notification + "signon.rememberSignons": False, + + # Prevent starting into safe mode after application crashes + "toolkit.startup.max_resumed_crashes": -1, + + # We want to collect telemetry, but we don't want to send in the results + "toolkit.telemetry.server": "https://%(server)s/dummy/telemetry/", + } + + 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): + 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 + # Alternative to default temporary directory + self.workspace = workspace + self.addons = addons + # Check if it is a Profile object or a path to profile + self.profile = None + if isinstance(profile, Profile): + self.profile = profile + else: + self.profile_path = profile + 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 + + @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 + + def _update_profile(self): + profile_args = {"preferences": deepcopy(self.required_prefs)} + profile_args["preferences"]["marionette.defaultPrefs.port"] = self.marionette_port + if self.prefs: + profile_args["preferences"].update(self.prefs) + if self.verbose: + level = "TRACE" if self.verbose >= 2 else "DEBUG" + profile_args["preferences"]["marionette.logging"] = level + if "-jsdebugger" in self.app_args: + profile_args["preferences"].update({ + "devtools.browsertoolbox.panel": "jsdebugger", + "devtools.debugger.remote-enabled": True, + "devtools.chrome.enabled": True, + "devtools.debugger.prompt-connection": False, + "marionette.debugging.clicktostart": True, + }) + if self.addons: + profile_args["addons"] = self.addons + + if hasattr(self, "profile_path") and self.profile is None: + if not self.profile_path: + if self.workspace: + profile_args["profile"] = tempfile.mkdtemp( + suffix=".mozrunner-{:.0f}".format(time.time()), + dir=self.workspace) + self.profile = Profile(**profile_args) + else: + profile_args["path_from"] = self.profile_path + profile_name = "{}-{:.0f}".format( + os.path.basename(self.profile_path), + time.time() + ) + if self.workspace: + profile_args["path_to"] = os.path.join(self.workspace, + profile_name) + self.profile = Profile.clone(**profile_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})' + raise NotImplementedError, msg.format(app, apps.keys()), tb + + return instance_class(*args, **kwargs) + + def start(self): + self._update_profile() + self.runner = self.runner_class(**self._get_runner_args()) + self.runner.start() + + def _get_runner_args(self): + process_args = { + "processOutputLine": [NullOutput()], + } + + if self.gecko_log == "-": + process_args["stream"] = sys.stdout + else: + process_args["logfile"] = self.gecko_log + + env = os.environ.copy() + + # 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, restart=False): + if not restart: + self.profile = None + + if self.runner: + self.runner.stop() + self.runner.cleanup() + + def restart(self, prefs=None, clean=True): + self.close(restart=True) + + if clean and self.profile: + self.profile.cleanup() + self.profile = None + + if prefs: + self.prefs = prefs + else: + self.prefs = None + self.start() + + +class FennecInstance(GeckoInstance): + fennec_prefs = { + # Enable output of dump() + "browser.dom.window.dump.enabled": True, + + # Disable Android snippets + "browser.snippets.enabled": False, + "browser.snippets.syncPromo.enabled": False, + "browser.snippets.firstrunHomepage.enabled": False, + + # Disable safebrowsing components + "browser.safebrowsing.downloads.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.1": False, + "browser.tabs.remote.autostart.2": False, + "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, *args, **kwargs): + super(FennecInstance, self).__init__(*args, **kwargs) + self.required_prefs.update(FennecInstance.fennec_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.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.runner = self.runner_class(**self._get_runner_args()) + try: + if self.connect_to_running_emulator: + self.runner.device.connect() + self.runner.start() + except Exception as e: + exc, val, tb = sys.exc_info() + message = "Error possibly due to runner or device args: {}" + raise exc, message.format(e.message), tb + # gecko_log comes from logcat when running with device/emulator + logcat_args = { + "filterspec": "Gecko", + "serial": self.runner.device.dm._deviceSerial + } + if self.gecko_log == "-": + logcat_args["stream"] = sys.stdout + else: + logcat_args["logfile"] = self.gecko_log + self.runner.device.start_logcat(**logcat_args) + + # forward marionette port (localhost:2828) + self.runner.device.dm.forward( + local="tcp:{}".format(self.marionette_port), + remote="tcp:{}".format(self.marionette_port)) + + def _get_runner_args(self): + process_args = { + "processOutputLine": [NullOutput()], + } + + runner_args = { + "app": self.package_name, + "avd_home": self.avd_home, + "adb_path": self.adb_path, + "binary": self.emulator_binary, + "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, restart=False): + super(FennecInstance, self).close(restart) + if self.runner and self.runner.device.connected: + self.runner.device.dm.remove_forward( + "tcp:{}".format(self.marionette_port)) + + +class DesktopInstance(GeckoInstance): + desktop_prefs = { + # Disable application updates + "app.update.enabled": False, + + # Enable output of dump() + "browser.dom.window.dump.enabled": 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, + + # Turn off about:newtab and make use of about:blank instead for new opened tabs + "browser.newtabpage.enabled": False, + # Assume the about:newtab page"s intro panels have been shown to not depend on + # which test runs first and happens to open about:newtab + "browser.newtabpage.introShown": True, + + # 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, + + # Avoid performing Reader Mode intros during tests + "browser.reader.detectedFirstArticle": True, + + # Disable safebrowsing components + "browser.safebrowsing.blockedURIs.enabled": False, + "browser.safebrowsing.downloads.enabled": False, + "browser.safebrowsing.forbiddenURIs.enabled": False, + "browser.safebrowsing.malware.enabled": False, + "browser.safebrowsing.phishing.enabled": False, + + # Disable updates to search engines + "browser.search.update": False, + + # Do not restore the last open set of tabs if the browser has crashed + "browser.sessionstore.resume_from_crash": False, + + # Don't check for the default web browser during startup + "browser.shell.checkDefaultBrowser": False, + + # Disable e10s by default + "browser.tabs.remote.autostart.1": False, + "browser.tabs.remote.autostart.2": False, + "browser.tabs.remote.autostart": False, + + # Needed for branded builds to prevent opening a second tab on startup + "browser.startup.homepage_override.mstone": "ignore", + # Start with a blank page by default + "browser.startup.page": 0, + + # Disable tab animation + "browser.tabs.animate": False, + + # Do not warn on exit when multiple tabs are open + "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, + + # Disable the UI tour + "browser.uitour.enabled": False, + + # Disable first-run welcome page + "startup.homepage_welcome_url": "about:blank", + "startup.homepage_welcome_url.additional": "", + } + + def __init__(self, *args, **kwargs): + super(DesktopInstance, self).__init__(*args, **kwargs) + self.required_prefs.update(DesktopInstance.desktop_prefs) + + +class NullOutput(object): + def __call__(self, line): + pass + + +apps = { + 'fennec': FennecInstance, + 'fxdesktop': DesktopInstance, +} + +app_ids = { + '{aa3c5121-dab2-40e2-81ca-7ea25febc110}': 'fennec', + '{ec8030f7-c20a-464f-9b0e-13a3a9e97384}': 'fxdesktop', +} diff --git a/testing/marionette/client/marionette_driver/gestures.py b/testing/marionette/client/marionette_driver/gestures.py new file mode 100644 index 000000000..55b27ec83 --- /dev/null +++ b/testing/marionette/client/marionette_driver/gestures.py @@ -0,0 +1,93 @@ +# 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 marionette import MultiActions, Actions + + +def smooth_scroll(marionette_session, start_element, axis, direction, + length, increments=None, wait_period=None, scroll_back=None): + """ + :param axis: y or x + :param direction: 0 for positive, and -1 for negative + :param length: total length of scroll scroll + :param increments: Amount to be moved per scrolling + :param wait_period: Seconds to wait between scrolling + :param scroll_back: Scroll back to original view? + """ + if axis not in ["x", "y"]: + raise Exception("Axis must be either 'x' or 'y'") + if direction not in [-1, 0]: + raise Exception("Direction must either be -1 negative or 0 positive") + increments = increments or 100 + wait_period = wait_period or 0.05 + scroll_back = scroll_back or False + current = 0 + if axis is "x": + if direction is -1: + offset = [-increments, 0] + else: + offset = [increments, 0] + else: + if direction is -1: + offset = [0, -increments] + else: + offset = [0, increments] + action = Actions(marionette_session) + action.press(start_element) + while (current < length): + current += increments + action.move_by_offset(*offset).wait(wait_period) + if scroll_back: + offset = [-value for value in offset] + while (current > 0): + current -= increments + action.move_by_offset(*offset).wait(wait_period) + action.release() + action.perform() + + +def pinch(marionette_session, element, x1, y1, x2, y2, x3, y3, x4, y4, duration=200): + """ + :param element: target + :param x1, y1: 1st finger starting position relative to the target + :param x3, y3: 1st finger ending position relative to the target + :param x2, y2: 2nd finger starting position relative to the target + :param x4, y4: 2nd finger ending position relative to the target + :param duration: Amount of time in milliseconds to complete the pinch. + """ + time = 0 + time_increment = 10 + if time_increment >= duration: + time_increment = duration + move_x1 = time_increment*1.0/duration * (x3 - x1) + move_y1 = time_increment*1.0/duration * (y3 - y1) + move_x2 = time_increment*1.0/duration * (x4 - x2) + move_y2 = time_increment*1.0/duration * (y4 - y2) + multiAction = MultiActions(marionette_session) + action1 = Actions(marionette_session) + action2 = Actions(marionette_session) + action1.press(element, x1, y1) + action2.press(element, x2, y2) + while (time < duration): + time += time_increment + action1.move_by_offset(move_x1, move_y1).wait(time_increment/1000) + action2.move_by_offset(move_x2, move_y2).wait(time_increment/1000) + action1.release() + action2.release() + multiAction.add(action1).add(action2).perform() + + +def long_press_without_contextmenu(marionette_session, element, time_in_seconds, x=None, y=None): + """ + :param element: The element to press. + :param time_in_seconds: Time in seconds to wait before releasing the press. + #x: Optional, x-coordinate to tap, relative to the top-left corner of the element. + #y: Optional, y-coordinate to tap, relative to the top-leftcorner of the element. + """ + action = Actions(marionette_session) + action.press(element, x, y) + action.move_by_offset(0, 0) + action.wait(time_in_seconds) + action.release() + action.perform() diff --git a/testing/marionette/client/marionette_driver/keys.py b/testing/marionette/client/marionette_driver/keys.py new file mode 100644 index 000000000..2a998c089 --- /dev/null +++ b/testing/marionette/client/marionette_driver/keys.py @@ -0,0 +1,84 @@ +# 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 000000000..7b0d91b44 --- /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://global/locale/about.dtd"], "about.version") + 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("localization: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("localization: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 000000000..7450d1fbb --- /dev/null +++ b/testing/marionette/client/marionette_driver/marionette.py @@ -0,0 +1,2153 @@ +# 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 +import warnings + +from contextlib import contextmanager + +import errors +import transport + +from .decorators import do_process_check +from .geckoinstance import GeckoInstance +from .keys import Keys +from .timeout import Timeouts + +WEBELEMENT_KEY = "ELEMENT" +W3C_WEBELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf" + + +class HTMLElement(object): + """Represents a DOM Element.""" + + def __init__(self, marionette, id): + self.marionette = marionette + assert(id is not None) + self.id = id + + def __str__(self): + return self.id + + def __eq__(self, other_element): + return self.id == other_element.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 `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 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("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("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): + self.marionette._send_message("clickElement", {"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("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("getElementText", body, key="value") + + def send_keys(self, *string): + """Sends the string via synthesized keypresses to the element.""" + keys = Marionette.convert_keys(*string) + body = {"id": self.id, "value": keys} + self.marionette._send_message("sendKeysToElement", body) + + def clear(self): + """Clears the input of the element.""" + self.marionette._send_message("clearElement", {"id": self.id}) + + def is_selected(self): + """Returns True if the element is selected.""" + body = {"id": self.id} + return self.marionette._send_message("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("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("isElementDisplayed", body, key="value") + + @property + def size(self): + """A dictionary with the size of the element.""" + warnings.warn("The size property has been deprecated and will be removed in a future version. \ + Please use HTMLElement#rect", DeprecationWarning) + rect = self.rect + return {"width": rect["width"], "height": rect["height"]} + + @property + def tag_name(self): + """The tag name of the element.""" + body = {"id": self.id} + return self.marionette._send_message("getElementTagName", body, key="value") + + @property + def location(self): + """Get an element's location on the page. + + The returned point will contain the x and y coordinates of the + top left-hand corner of the given element. The point (0,0) + refers to the upper-left corner of the document. + + :returns: a dictionary containing x and y as entries + """ + warnings.warn("The location property has been deprecated and will be removed in a future version. \ + Please use HTMLElement#rect", DeprecationWarning) + rect = self.rect + return {"x": rect["x"], "y": rect["y"]} + + @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``. + """ + body = {"id": self.id} + return self.marionette._send_message( + "getElementRect", body, key="value" if self.marionette.protocol == 1 else None) + + 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( + "getElementValueOfCssProperty", body, key="value") + + +class MouseButton(object): + """Enum-like class for mouse button constants.""" + LEFT = 0 + MIDDLE = 1 + RIGHT = 2 + + +class Actions(object): + ''' + An Action object represents a set of actions that are executed in a particular order. + + All action methods (press, etc.) return the Actions object itself, to make + it easy to create a chain of events. + + Example usage: + + :: + + # get html file + testAction = marionette.absolute_url("testFool.html") + # navigate to the file + marionette.navigate(testAction) + # find element1 and element2 + element1 = marionette.find_element(By.ID, "element1") + element2 = marionette.find_element(By.ID, "element2") + # create action object + action = Actions(marionette) + # add actions (press, wait, move, release) into the object + action.press(element1).wait(5). move(element2).release() + # fire all the added events + action.perform() + ''' + + def __init__(self, marionette): + self.action_chain = [] + self.marionette = marionette + self.current_id = None + + def press(self, element, x=None, y=None): + ''' + Sends a 'touchstart' event to this element. + + If no coordinates are given, it will be targeted at the center of the + element. If given, it will be targeted at the (x,y) coordinates + relative to the top-left corner of the element. + + :param element: The element to press on. + :param x: Optional, x-coordinate to tap, relative to the top-left + corner of the element. + :param y: Optional, y-coordinate to tap, relative to the top-left + corner of the element. + ''' + element = element.id + self.action_chain.append(['press', element, x, y]) + return self + + def release(self): + ''' + Sends a 'touchend' event to this element. + + May only be called if press() has already be called on this element. + + If press and release are chained without a move action between them, + then it will be processed as a 'tap' event, and will dispatch the + expected mouse events ('mousemove' (if necessary), 'mousedown', + 'mouseup', 'mouseclick') after the touch events. If there is a wait + period between press and release that will trigger a contextmenu, + then the 'contextmenu' menu event will be fired instead of the + touch/mouse events. + ''' + self.action_chain.append(['release']) + return self + + def move(self, element): + ''' + Sends a 'touchmove' event at the center of the target element. + + :param element: Element to move towards. + + May only be called if press() has already be called. + ''' + element = element.id + self.action_chain.append(['move', element]) + return self + + def move_by_offset(self, x, y): + ''' + Sends 'touchmove' event to the given x, y coordinates relative to the + top-left of the currently touched element. + + May only be called if press() has already be called. + + :param x: Specifies x-coordinate of move event, relative to the + top-left corner of the element. + :param y: Specifies y-coordinate of move event, relative to the + top-left corner of the element. + ''' + self.action_chain.append(['moveByOffset', x, y]) + return self + + def wait(self, time=None): + ''' + Waits for specified time period. + + :param time: Time in seconds to wait. If time is None then this has no effect + for a single action chain. If used inside a multi-action chain, + then time being None indicates that we should wait for all other + currently executing actions that are part of the chain to complete. + ''' + self.action_chain.append(['wait', time]) + return self + + def cancel(self): + ''' + Sends 'touchcancel' event to the target of the original 'touchstart' event. + + May only be called if press() has already be called. + ''' + self.action_chain.append(['cancel']) + return self + + def tap(self, element, x=None, y=None): + ''' + Performs a quick tap on the target element. + + :param element: The element to tap. + :param x: Optional, x-coordinate of tap, relative to the top-left + corner of the element. If not specified, default to center of + element. + :param y: Optional, y-coordinate of tap, relative to the top-left + corner of the element. If not specified, default to center of + element. + + This is equivalent to calling: + + :: + + action.press(element, x, y).release() + ''' + element = element.id + self.action_chain.append(['press', element, x, y]) + self.action_chain.append(['release']) + return self + + def double_tap(self, element, x=None, y=None): + ''' + Performs a double tap on the target element. + + :param element: The element to double tap. + :param x: Optional, x-coordinate of double tap, relative to the + top-left corner of the element. + :param y: Optional, y-coordinate of double tap, relative to the + top-left corner of the element. + ''' + element = element.id + self.action_chain.append(['press', element, x, y]) + self.action_chain.append(['release']) + self.action_chain.append(['press', element, x, y]) + self.action_chain.append(['release']) + return self + + def click(self, element, button=MouseButton.LEFT, count=1): + ''' + Performs a click with additional parameters to allow for double clicking, + right click, middle click, etc. + + :param element: The element to click. + :param button: The mouse button to click (indexed from 0, left to right). + :param count: Optional, the count of clicks to synthesize (for double + click events). + ''' + el = element.id + self.action_chain.append(['click', el, button, count]) + return self + + def context_click(self, element): + ''' + Performs a context click on the specified element. + + :param element: The element to context click. + ''' + return self.click(element, button=MouseButton.RIGHT) + + def middle_click(self, element): + ''' + Performs a middle click on the specified element. + + :param element: The element to middle click. + ''' + return self.click(element, button=MouseButton.MIDDLE) + + def double_click(self, element): + ''' + Performs a double click on the specified element. + + :param element: The element to double click. + ''' + return self.click(element, count=2) + + def flick(self, element, x1, y1, x2, y2, duration=200): + ''' + Performs a flick gesture on the target element. + + :param element: The element to perform the flick gesture on. + :param x1: Starting x-coordinate of flick, relative to the top left + corner of the element. + :param y1: Starting y-coordinate of flick, relative to the top left + corner of the element. + :param x2: Ending x-coordinate of flick, relative to the top left + corner of the element. + :param y2: Ending y-coordinate of flick, relative to the top left + corner of the element. + :param duration: Time needed for the flick gesture for complete (in + milliseconds). + ''' + element = element.id + elapsed = 0 + time_increment = 10 + if time_increment >= duration: + time_increment = duration + move_x = time_increment*1.0/duration * (x2 - x1) + move_y = time_increment*1.0/duration * (y2 - y1) + self.action_chain.append(['press', element, x1, y1]) + while elapsed < duration: + elapsed += time_increment + self.action_chain.append(['moveByOffset', move_x, move_y]) + self.action_chain.append(['wait', time_increment/1000]) + self.action_chain.append(['release']) + return self + + def long_press(self, element, time_in_seconds, x=None, y=None): + ''' + Performs a long press gesture on the target element. + + :param element: The element to press. + :param time_in_seconds: Time in seconds to wait before releasing the press. + :param x: Optional, x-coordinate to tap, relative to the top-left + corner of the element. + :param y: Optional, y-coordinate to tap, relative to the top-left + corner of the element. + + This is equivalent to calling: + + :: + + action.press(element, x, y).wait(time_in_seconds).release() + + ''' + element = element.id + self.action_chain.append(['press', element, x, y]) + self.action_chain.append(['wait', time_in_seconds]) + self.action_chain.append(['release']) + return self + + def key_down(self, key_code): + """ + Perform a "keyDown" action for the given key code. Modifier keys are + respected by the server for the course of an action chain. + + :param key_code: The key to press as a result of this action. + """ + self.action_chain.append(['keyDown', key_code]) + return self + + def key_up(self, key_code): + """ + Perform a "keyUp" action for the given key code. Modifier keys are + respected by the server for the course of an action chain. + :param key_up: The key to release as a result of this action. + """ + self.action_chain.append(['keyUp', key_code]) + return self + + def perform(self): + """Sends the action chain built so far to the server side for + execution and clears the current chain of actions.""" + body = {"chain": self.action_chain, "nextId": self.current_id} + self.current_id = self.marionette._send_message("actionChain", body, key="value") + self.action_chain = [] + return self + + +class MultiActions(object): + ''' + A MultiActions object represents a sequence of actions that may be + performed at the same time. Its intent is to allow the simulation + of multi-touch gestures. + Usage example: + + :: + + # create multiaction object + multitouch = MultiActions(marionette) + # create several action objects + action_1 = Actions(marionette) + action_2 = Actions(marionette) + # add actions to each action object/finger + action_1.press(element1).move_to(element2).release() + action_2.press(element3).wait().release(element3) + # fire all the added events + multitouch.add(action_1).add(action_2).perform() + ''' + + def __init__(self, marionette): + self.multi_actions = [] + self.max_length = 0 + self.marionette = marionette + + def add(self, action): + ''' + Adds a set of actions to perform. + + :param action: An Actions object. + ''' + self.multi_actions.append(action.action_chain) + if len(action.action_chain) > self.max_length: + self.max_length = len(action.action_chain) + return self + + def perform(self): + """Perform all the actions added to this object.""" + body = {"value": self.multi_actions, "max_length": self.max_length} + self.marionette._send_message("multiAction", body) + + +class Alert(object): + """A class for interacting with alerts. + + :: + + Alert(marionette).accept() + Alert(merionette).dismiss() + """ + + def __init__(self, marionette): + self.marionette = marionette + + def accept(self): + """Accept a currently displayed modal dialog.""" + self.marionette._send_message("acceptDialog") + + def dismiss(self): + """Dismiss a currently displayed modal dialog.""" + self.marionette._send_message("dismissDialog") + + @property + def text(self): + """Return the currently displayed text in a tab modal.""" + return self.marionette._send_message("getTextFromDialog", key="value") + + def send_keys(self, *string): + """Send keys to the currently displayed text input area in an open + tab modal dialog.""" + body = {"value": Marionette.convert_keys(*string)} + self.marionette._send_message("sendKeysToDialog", body) + + +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 = 65 # 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="localhost", port=2828, app=None, bin=None, + baseurl=None, socket_timeout=DEFAULT_SOCKET_TIMEOUT, + 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 localhost. + :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 = host + self.port = self.local_port = int(port) + self.bin = bin + self.client = None + self.instance = None + self.session = None + self.session_id = None + self.process_id = None + self.profile = None + self.window = None + self.chrome_window = None + self.baseurl = baseurl + self._test_name = None + self.socket_timeout = socket_timeout + self.crashed = 0 + + self.startup_timeout = int(startup_timeout or self.DEFAULT_STARTUP_TIMEOUT) + if self.bin: + if not Marionette.is_port_available(self.port, host=self.host): + ex_msg = "{0}:{1} is unavailable.".format(self.host, self.port) + raise errors.MarionetteException(message=ex_msg) + + self.instance = GeckoInstance.create( + app, host=self.host, port=self.port, bin=self.bin, **instance_args) + self.instance.start() + self.raise_for_port(timeout=self.startup_timeout) + + self.timeout = Timeouts(self) + + @property + def profile_path(self): + if self.instance and self.instance.profile: + return self.instance.profile.profile + + def cleanup(self): + if self.session: + 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: + self.instance.close() + + def __del__(self): + self.cleanup() + + @staticmethod + def is_port_available(port, host=''): + 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)) + return True + except socket.error: + return False + finally: + s.close() + + def wait_for_port(self, timeout=None): + """Wait until Marionette server has been created the communication socket. + + :param timeout: Timeout in seconds for the server to be ready. + + """ + if timeout is None: + timeout = self.DEFAULT_STARTUP_TIMEOUT + + runner = None + if self.instance is not None: + runner = self.instance.runner + + poll_interval = 0.1 + starttime = datetime.datetime.now() + + while datetime.datetime.now() - starttime < datetime.timedelta(seconds=timeout): + # If the instance we want to connect to is not running return immediately + if runner is not None and not runner.is_running(): + return False + + sock = None + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(0.5) + sock.connect((self.host, self.port)) + data = sock.recv(16) + if ":" in data: + return True + except socket.error: + pass + finally: + if sock is not None: + sock.close() + + time.sleep(poll_interval) + + return False + + @do_process_check + def raise_for_port(self, timeout=None): + """Raise socket.timeout if no connection can be established. + + :param timeout: Timeout in seconds for the server to be ready. + + """ + if not self.wait_for_port(timeout): + 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 != "newSession": + raise errors.MarionetteException("Please start a session") + + try: + if self.protocol < 3: + data = {"name": name} + if params: + data["parameters"] = params + self.client.send(data) + msg = self.client.receive() + + else: + msg = self.client.request(name, params) + + except IOError: + self.delete_session(send_request=False) + raise + + res, err = msg.result, msg.error + if err: + self._handle_error(err) + + if key is not None: + return self._unwrap_response(res.get(key)) + else: + return self._unwrap_response(res) + + def _unwrap_response(self, value): + if isinstance(value, dict) and (WEBELEMENT_KEY in value or + W3C_WEBELEMENT_KEY in value): + if value.get(WEBELEMENT_KEY): + return HTMLElement(self, value.get(WEBELEMENT_KEY)) + else: + return HTMLElement(self, value.get(W3C_WEBELEMENT_KEY)) + elif isinstance(value, list): + return list(self._unwrap_response(item) for item in value) + else: + return value + + def _handle_error(self, obj): + if self.protocol == 1: + if "error" not in obj or not isinstance(obj["error"], dict): + raise errors.MarionetteException( + "Malformed packet, expected key 'error' to be a dict: {}".format(obj)) + error = obj["error"].get("status") + message = obj["error"].get("message") + stacktrace = obj["error"].get("stacktrace") + + else: + 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, val, 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: + raise exc, val, 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.DEFAULT_SHUTDOWN_TIMEOUT) + + if returncode is None: + message = ('Process killed because the connection to Marionette server is ' + 'lost. Check gecko.log for errors') + # This will force-close the application without sending any other message. + self.cleanup() + else: + # If Firefox quit itself check if there was a crash + crash_count = self.check_for_crash() + + if crash_count > 0: + if returncode == 0: + message = 'Content process crashed' + else: + message = 'Process crashed (Exit code: {returncode})' + else: + message = 'Process has been unexpectedly closed (Exit code: {returncode})' + + self.delete_session(send_request=False, reset_session_id=True) + + message += ' (Reason: {reason})' + + raise IOError, message.format(returncode=returncode, reason=val), 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 typing + + def get_permission(self, perm): + script = """ + let value = { + 'url': document.nodePrincipal.URI.spec, + 'appId': document.nodePrincipal.appId, + 'isInIsolatedMozBrowserElement': document.nodePrincipal.isInIsolatedMozBrowserElement, + 'type': arguments[0] + }; + return value;""" + with self.using_context("content"): + value = self.execute_script(script, script_args=(perm,), sandbox="system") + + with self.using_context("chrome"): + permission = self.execute_script(""" + Components.utils.import("resource://gre/modules/Services.jsm"); + let perm = arguments[0]; + let secMan = Services.scriptSecurityManager; + let attrs = {appId: perm.appId, + inIsolatedMozBrowser: perm.isInIsolatedMozBrowserElement}; + let principal = secMan.createCodebasePrincipal( + Services.io.newURI(perm.url, null, null), + attrs); + let testPerm = Services.perms.testPermissionFromPrincipal( + principal, perm.type); + return testPerm; + """, script_args=(value,)) + return permission + + def push_permission(self, perm, allow): + script = """ + let allow = arguments[0]; + if (typeof(allow) == "boolean") { + if (allow) { + allow = Components.interfaces.nsIPermissionManager.ALLOW_ACTION; + } + else { + allow = Components.interfaces.nsIPermissionManager.DENY_ACTION; + } + } + let perm_type = arguments[1]; + + Components.utils.import("resource://gre/modules/Services.jsm"); + window.wrappedJSObject.permChanged = false; + window.wrappedJSObject.permObserver = function(subject, topic, data) { + if (topic == "perm-changed") { + let permission = subject.QueryInterface(Components.interfaces.nsIPermission); + if (perm_type == permission.type) { + Services.obs.removeObserver(window.wrappedJSObject.permObserver, + "perm-changed"); + window.wrappedJSObject.permChanged = true; + } + } + }; + Services.obs.addObserver(window.wrappedJSObject.permObserver, + "perm-changed", false); + + let value = { + 'url': document.nodePrincipal.URI.spec, + 'appId': document.nodePrincipal.appId, + 'isInIsolatedMozBrowserElement': document.nodePrincipal.isInIsolatedMozBrowserElement, + 'type': perm_type, + 'action': allow + }; + return value; + """ + with self.using_context("content"): + perm = self.execute_script(script, script_args=(allow, perm,), sandbox="system") + + current_perm = self.get_permission(perm["type"]) + if current_perm == perm["action"]: + with self.using_context("content"): + self.execute_script(""" + Components.utils.import("resource://gre/modules/Services.jsm"); + Services.obs.removeObserver(window.wrappedJSObject.permObserver, + "perm-changed"); + """, sandbox="system") + return + + with self.using_context("chrome"): + self.execute_script(""" + Components.utils.import("resource://gre/modules/Services.jsm"); + let perm = arguments[0]; + let secMan = Services.scriptSecurityManager; + let attrs = {appId: perm.appId, + inIsolatedMozBrowser: perm.isInIsolatedMozBrowserElement}; + let principal = secMan.createCodebasePrincipal(Services.io.newURI(perm.url, + null, null), + attrs); + Services.perms.addFromPrincipal(principal, perm.type, perm.action); + return true; + """, script_args=(perm,)) + + with self.using_context("content"): + self.execute_async_script(""" + let wait = function() { + if (window.wrappedJSObject.permChanged) { + marionetteScriptFinished(); + } else { + window.setTimeout(wait, 100); + } + }(); + """, sandbox="system") + + @contextmanager + def using_permissions(self, perms): + ''' + Sets permissions for code being executed in a `with` block, + and restores them on exit. + + :param perms: A dict containing one or more perms and their + values to be set. + + Usage example:: + + with marionette.using_permissions({'systemXHR': True}): + ... do stuff ... + ''' + original_perms = {} + for perm in perms: + original_perms[perm] = self.get_permission(perm) + self.push_permission(perm, perms[perm]) + + try: + yield + finally: + for perm in original_perms: + self.push_permission(perm, original_perms[perm]) + + def clear_pref(self, pref): + """Clear the user-defined value from the specified preference. + + :param pref: Name of the preference. + """ + with self.using_context(self.CONTEXT_CHROME): + self.execute_script(""" + Components.utils.import("resource://gre/modules/Preferences.jsm"); + Preferences.reset(arguments[0]); + """, script_args=(pref,)) + + def get_pref(self, pref, default_branch=False, value_type="nsISupportsString"): + """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. + Defaults to `nsISupportsString`. Other possible values are: + `nsILocalFile`, and `nsIPrefLocalizedString`. + + Usage example:: + marionette.get_pref("browser.tabs.warnOnClose") + + """ + with self.using_context(self.CONTEXT_CHROME): + pref_value = self.execute_script(""" + Components.utils.import("resource://gre/modules/Preferences.jsm"); + + let pref = arguments[0]; + let defaultBranch = arguments[1]; + let valueType = arguments[2]; + + prefs = new Preferences({defaultBranch: defaultBranch}); + return prefs.get(pref, null, Components.interfaces[valueType]); + """, script_args=(pref, default_branch, value_type)) + return pref_value + + def set_pref(self, pref, value, default_branch=False): + """Set the value of the specified preference. + + :param pref: Name of the preference. + :param value: The value to set the preference to. If the value is None, + reset the preference to its default value. If no default + value exists, the preference will cease to exist. + :param default_branch: Optional, if `True` the preference value will + be written to the default branch, and will remain until + the application gets restarted. Otherwise a user-defined + value is set. Defaults to `False`. + + Usage example:: + marionette.set_pref("browser.tabs.warnOnClose", True) + + """ + with self.using_context(self.CONTEXT_CHROME): + if value is None: + self.clear_pref(pref) + return + + self.execute_script(""" + Components.utils.import("resource://gre/modules/Preferences.jsm"); + + let pref = arguments[0]; + let value = arguments[1]; + let defaultBranch = arguments[2]; + + prefs = new Preferences({defaultBranch: defaultBranch}); + prefs.set(pref, value); + """, script_args=(pref, value, default_branch)) + + def set_prefs(self, prefs, default_branch=False): + """Set the value of a list of preferences. + + :param prefs: A dict containing one or more preferences and their values + to be set. See `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 `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 prefs.iteritems(): + 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("getContext", key="value") + self.delete_session() + self.instance.restart(prefs) + self.raise_for_port() + self.start_session() + + # Restore the context as used before the restart + self.set_context(context) + + def _request_in_app_shutdown(self, shutdown_flags=None): + """Terminate the currently running instance from inside the application. + + :param shutdown_flags: If specified use additional flags for the shutdown + of the application. Possible values here correspond + to constants in nsIAppStartup: http://mzl.la/1X0JZsC. + """ + flags = set([]) + if shutdown_flags: + flags.add(shutdown_flags) + + # Trigger a 'quit-application-requested' observer notification so that + # components can safely shutdown before quitting the application. + with self.using_context("chrome"): + canceled = self.execute_script(""" + Components.utils.import("resource://gre/modules/Services.jsm"); + let cancelQuit = Components.classes["@mozilla.org/supports-PRBool;1"]. + createInstance(Components.interfaces.nsISupportsPRBool); + Services.obs.notifyObservers(cancelQuit, "quit-application-requested", null); + return cancelQuit.data; + """) + if canceled: + raise errors.MarionetteException("Something canceled the quit application request") + + self._send_message("quitApplication", {"flags": list(flags)}) + + @do_process_check + def quit(self, in_app=False, callback=None): + """Terminate the currently running instance. + + This command will delete the active marionette session. It also allows + manipulation of eg. the profile data while the application is not running. + To start the application again, start_session() has to be called. + + :param in_app: If True, marionette will cause a quit from within the + browser. Otherwise the browser will be quit immediately + by killing the process. + :param callback: If provided and `in_app` is True, the callback will + be used to trigger the shutdown. + """ + if not self.instance: + raise errors.MarionetteException("quit() can only be called " + "on Gecko instances launched by Marionette") + + if in_app: + if callable(callback): + self._send_message("acceptConnections", {"value": False}) + callback() + else: + self._request_in_app_shutdown() + + # Ensure to explicitely mark the session as deleted + self.delete_session(send_request=False, reset_session_id=True) + + # Give the application some time to shutdown + self.instance.runner.wait(timeout=self.DEFAULT_SHUTDOWN_TIMEOUT) + else: + self.delete_session(reset_session_id=True) + self.instance.close() + + @do_process_check + def restart(self, clean=False, in_app=False, callback=None): + """ + This will terminate the currently running instance, and spawn a new instance + with the same profile and then reuse the session id when creating a session again. + + :param clean: If False the same profile will be used after the restart. Note + that the in app initiated restart always maintains the same + profile. + :param in_app: If True, marionette will cause a restart from within the + browser. Otherwise the browser will be restarted immediately + by killing the process. + :param callback: If provided and `in_app` is True, the callback will be + used to trigger the restart. + """ + if not self.instance: + raise errors.MarionetteException("restart() can only be called " + "on Gecko instances launched by Marionette") + + context = self._send_message("getContext", key="value") + session_id = self.session_id + + if in_app: + if clean: + raise ValueError("An in_app restart cannot be triggered with the clean flag set") + + if callable(callback): + self._send_message("acceptConnections", {"value": False}) + callback() + else: + self._request_in_app_shutdown("eRestart") + + # Ensure to explicitely mark the session as deleted + self.delete_session(send_request=False, reset_session_id=True) + + try: + self.raise_for_port() + except socket.timeout: + if self.instance.runner.returncode is not None: + exc, val, tb = sys.exc_info() + self.cleanup() + raise exc, "Requested restart of the application was aborted", tb + + else: + self.delete_session() + self.instance.restart(clean=clean) + self.raise_for_port() + + self.start_session(session_id=session_id) + + # Restore the context as used before the restart + self.set_context(context) + + if in_app and self.process_id: + # In some cases Firefox restarts itself by spawning into a new process group. + # As long as mozprocess cannot track that behavior (bug 1284864) we assist by + # informing about the new process id. + self.instance.runner.process_handler.check_for_detached(self.process_id) + + def absolute_url(self, relative_url): + ''' + Returns an absolute url for files served from Marionette's www directory. + + :param relative_url: The url of a static file, relative to Marionette's www directory. + ''' + return "{0}{1}".format(self.baseurl, relative_url) + + @do_process_check + def start_session(self, capabilities=None, session_id=None, timeout=60): + """Create a new Marionette session. + + This method must be called before performing any other action. + + :param capabilities: An optional dict of desired or required capabilities. + :param timeout: Timeout in seconds for the server to be ready. + :param session_id: unique identifier for the session. If no session id is + passed in then one will be generated by the marionette server. + + :returns: A dict of the capabilities offered. + + """ + self.crashed = 0 + + if self.instance: + returncode = self.instance.runner.returncode + if returncode is not None: + # We're managing a binary which has terminated, so restart it. + self.instance.restart() + + self.client = transport.TcpTransport( + self.host, + self.port, + self.socket_timeout) + + # Call wait_for_port() before attempting to connect in + # the event gecko hasn't started yet. + timeout = timeout or self.startup_timeout + self.wait_for_port(timeout=timeout) + self.protocol, _ = self.client.connect() + + body = {"capabilities": capabilities, "sessionId": session_id} + resp = self._send_message("newSession", body) + + self.session_id = resp["sessionId"] + self.session = resp["value"] if self.protocol == 1 else resp["capabilities"] + # fallback to processId can be removed in Firefox 55 + self.process_id = self.session.get("moz:processID", self.session.get("processId")) + self.profile = self.session.get("moz:profile") + + return self.session + + @property + def test_name(self): + return self._test_name + + @test_name.setter + def test_name(self, test_name): + self._send_message("setTestName", {"value": test_name}) + self._test_name = test_name + + def delete_session(self, send_request=True, reset_session_id=False): + """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 send. Use `False` in case of eg. in_app restart() + or quit(), which trigger a deletion themselves. Defaults to `True`. + :param reset_session_id: Optional, if `True` the current session id will + be reset, which will require an explicit call to `start_session()` before + the test can continue. Defaults to `False`. + """ + try: + if send_request: + self._send_message("deleteSession") + finally: + if reset_session_id: + self.session_id = None + self.session = None + self.process_id = None + self.profile = 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 + + def set_script_timeout(self, timeout): + """Sets the maximum number of ms that an asynchronous script is + allowed to run. + + If a script does not return in the specified amount of time, + a ScriptTimeoutException is raised. + + :param timeout: The maximum number of milliseconds an asynchronous + script can run without causing an ScriptTimeoutException to + be raised + + .. note:: `set_script_timeout` is deprecated, please use + `timeout.script` setter. + + """ + warnings.warn( + "set_script_timeout is deprecated, please use timeout.script setter", + DeprecationWarning) + self.timeout.script = timeout / 1000 + + def set_search_timeout(self, timeout): + """Sets a timeout for the find methods. + + When searching for an element using + either :class:`Marionette.find_element` or + :class:`Marionette.find_elements`, the method will continue + trying to locate the element for up to timeout ms. This can be + useful if, for example, the element you're looking for might + not exist immediately, because it belongs to a page which is + currently being loaded. + + :param timeout: Timeout in milliseconds. + + .. note:: `set_search_timeout` is deprecated, please use + `timeout.implicit` setter. + + """ + warnings.warn( + "set_search_timeout is deprecated, please use timeout.implicit setter", + DeprecationWarning) + self.timeout.implicit = timeout / 1000 + + def set_page_load_timeout(self, timeout): + """Sets a timeout for loading pages. + + A page load timeout specifies the amount of time the Marionette + instance should wait for a page load operation to complete. A + ``TimeoutException`` is returned if this limit is exceeded. + + :param timeout: Timeout in milliseconds. + + .. note:: `set_page_load_timeout` is deprecated, please use + `timeout.page_load` setter. + + """ + warnings.warn( + "set_page_load_timeout is deprecated, please use timeout.page_load setter", + DeprecationWarning) + self.timeout.page_load = timeout / 1000 + + @property + def current_window_handle(self): + """Get the current window's handle. + + Returns an opaque server-assigned identifier to this window + that uniquely identifies it within this Marionette instance. + This can be used to switch to this window at a later point. + + :returns: unique window handle + :rtype: string + """ + self.window = self._send_message("getWindowHandle", key="value") + return self.window + + @property + def current_chrome_window_handle(self): + """Get the current chrome window's handle. Corresponds to + a chrome window that may itself contain tabs identified by + window_handles. + + Returns an opaque server-assigned identifier to this window + that uniquely identifies it within this Marionette instance. + This can be used to switch to this window at a later point. + + :returns: unique window handle + :rtype: string + """ + self.chrome_window = self._send_message( + "getCurrentChromeWindowHandle", key="value") + return self.chrome_window + + def get_window_position(self): + """Get the current window's position. + + :returns: a dictionary with x and y + """ + return self._send_message( + "getWindowPosition", key="value" if self.protocol == 1 else None) + + def set_window_position(self, x, y): + """Set the position of the current window + + :param x: x coordinate for the top left of the window + :param y: y coordinate for the top left of the window + """ + self._send_message("setWindowPosition", {"x": x, "y": y}) + + @property + def title(self): + """Current title of the active window.""" + return self._send_message("getTitle", key="value") + + @property + def window_handles(self): + """Get list of windows in the current context. + + If called in the content context it will return a list of + references to all available browser windows. Called in the + chrome context, it will list all available windows, not just + browser windows (e.g. not just navigator.browser). + + Each window handle is assigned by the server, and the list of + strings returned does not have a guaranteed ordering. + + :returns: Unordered list of unique window handles as strings + """ + return self._send_message( + "getWindowHandles", key="value" if self.protocol == 1 else None) + + @property + def chrome_window_handles(self): + """Get a list of currently open chrome windows. + + Each window handle is assigned by the server, and the list of + strings returned does not have a guaranteed ordering. + + :returns: Unordered list of unique chrome window handles as strings + """ + return self._send_message( + "getChromeWindowHandles", key="value" if self.protocol == 1 else None) + + @property + def page_source(self): + """A string representation of the DOM.""" + return self._send_message("getPageSource", key="value") + + 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("close") + + 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("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("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("getContext", key="value") + self.set_context(context) + try: + yield + finally: + self.set_context(scope) + + def switch_to_alert(self): + """Returns an 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, window_id, focus=True): + """Switch to the specified window; subsequent commands will be + directed at the new window. + + :param window_id: The id or name of the window to switch to. + + :param focus: A boolean value which determins whether to focus + the window that we just switched to. + """ + body = {"focus": focus, "name": window_id} + self._send_message("switchToWindow", body) + self.window = window_id + + def get_active_frame(self): + """Returns an HTMLElement representing the frame Marionette is + currently acting on.""" + return self._send_message("getActiveFrame", key="value") + + 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("switchToParentFrame") + + def switch_to_frame(self, frame=None, focus=True): + """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 ``HTMLElement``, an integer index, string name, or an + ID attribute. If you call ``switch_to_frame`` without an + argument, it will switch to the top-level frame. + + :param focus: A boolean value which determins whether to focus + the frame that we just switched to. + """ + body = {"focus": focus} + if isinstance(frame, HTMLElement): + body["element"] = frame.id + elif frame is not None: + body["id"] = frame + self._send_message("switchToFrame", body) + + def switch_to_shadow_root(self, host=None): + """Switch the current context to the specified host's Shadow DOM. + Subsequent commands will operate in the context of the specified Shadow + DOM, if applicable. + + :param host: A reference to the host element containing Shadow DOM. + This can be an ``HTMLElement``. If you call + ``switch_to_shadow_root`` without an argument, it will switch to the + parent Shadow DOM or the top-level frame. + """ + body = {} + if isinstance(host, HTMLElement): + body["id"] = host.id + return self._send_message("switchToShadowRoot", 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("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. + """ + 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.readState` is "complete". + + In chrome context it will change the current `window`'s location + to the supplied URL and wait until `document.readState` equals + "complete" or the page timeout duration has elapsed. + + :param url: The URL to navigate to. + """ + self._send_message("get", {"url": url}) + + def go_back(self): + """Causes the browser to perform a back navigation.""" + self._send_message("goBack") + + def go_forward(self): + """Causes the browser to perform a forward navigation.""" + self._send_message("goForward") + + def refresh(self): + """Causes the browser to perform to refresh the current page.""" + self._send_message("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 = {W3C_WEBELEMENT_KEY: args.id, + WEBELEMENT_KEY: args.id} + elif (isinstance(args, bool) or isinstance(args, basestring) or + isinstance(args, int) or isinstance(args, float) or args is None): + wrapped = args + return wrapped + + def _from_json(self, value): + if isinstance(value, list): + unwrapped = [] + for item in value: + unwrapped.append(self._from_json(item)) + elif isinstance(value, dict): + unwrapped = {} + for key in value: + if key == W3C_WEBELEMENT_KEY: + unwrapped = HTMLElement(self, value[key]) + break + elif key == WEBELEMENT_KEY: + unwrapped = HTMLElement(self, value[key]) + break + else: + unwrapped[key] = self._from_json(value[key]) + else: + unwrapped = value + return unwrapped + + def execute_js_script(self, script, script_args=(), async=True, + new_sandbox=True, script_timeout=None, + inactivity_timeout=None, filename=None, + sandbox='default'): + args = self._to_json(script_args) + body = {"script": script, + "args": args, + "async": async, + "newSandbox": new_sandbox, + "scriptTimeout": script_timeout, + "inactivityTimeout": inactivity_timeout, + "filename": filename, + "line": None} + rv = self._send_message("executeJSScript", body, key="value") + return self._from_json(rv) + + 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 + set_context() call, or to the CONTEXT_CONTENT context if 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 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 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. + + 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" + + """ + args = self._to_json(script_args) + stack = traceback.extract_stack() + frame = stack[-2:-1][0] # grab the second-to-last frame + body = {"script": script, + "args": args, + "newSandbox": new_sandbox, + "sandbox": sandbox, + "scriptTimeout": script_timeout, + "line": int(frame[1]), + "filename": os.path.basename(frame[0])} + rv = self._send_message("executeScript", body, key="value") + return self._from_json(rv) + + def execute_async_script(self, script, script_args=(), new_sandbox=True, + sandbox="default", script_timeout=None, + debug_script=False): + """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 + set_context() call, or to the CONTEXT_CONTENT context if + 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 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 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 debug_script: Capture javascript exceptions when in + `CONTEXT_CHROME` context. + + Usage example: + + :: + + marionette.timeout.script = 10 + result = self.marionette.execute_async_script(''' + // this script waits 5 seconds, and then returns the number 1 + setTimeout(function() { + marionetteScriptFinished(1); + }, 5000); + ''') + assert result == 1 + """ + args = self._to_json(script_args) + stack = traceback.extract_stack() + frame = stack[-2:-1][0] # grab the second-to-last frame + body = {"script": script, + "args": args, + "newSandbox": new_sandbox, + "sandbox": sandbox, + "scriptTimeout": script_timeout, + "line": int(frame[1]), + "filename": os.path.basename(frame[0]), + "debug_script": debug_script} + rv = self._send_message("executeAsyncScript", body, key="value") + return self._from_json(rv) + + def find_element(self, method, target, id=None): + """Returns an HTMLElement instances that matches the specified + method and target in the current context. + + An HTMLElement instance may be used to call other methods on the + element, such as 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 ``timeout.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", "xpath", "anon" and "anon + attribute". 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("findElement", body, key="value") + + def find_elements(self, method, target, id=None): + """Returns a list of all HTMLElement instances that match the + specified method and target in the current context. + + An HTMLElement instance may be used to call other methods on the + element, such as 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 ``timeout.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", "xpath", "anon" and "anon + attribute". 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( + "findElements", body, key="value" if self.protocol == 1 else None) + + def get_active_element(self): + el_or_ref = self._send_message("getActiveElement", key="value") + if self.protocol < 3: + return HTMLElement(self, el_or_ref) + return el_or_ref + + def log(self, msg, level="INFO"): + """Stores a timestamped log message in the Marionette server + for later retrieval. + + :param msg: String with message to log. + :param level: String with log level (e.g. "INFO" or "DEBUG"). + Defaults to "INFO". + """ + body = {"value": msg, "level": level} + self._send_message("log", body) + + def get_logs(self): + """Returns the list of logged messages. + + Each log message is an array with three string elements: the level, + the message, and a date. + + Usage example:: + + marionette.log("I AM INFO") + marionette.log("I AM ERROR", "ERROR") + logs = marionette.get_logs() + assert logs[0][1] == "I AM INFO" + assert logs[1][1] == "I AM ERROR" + """ + return self._send_message("getLogs", + key="value" if self.protocol == 1 else None) + + def import_script(self, js_file): + """Imports a script into the scope of the execute_script and + execute_async_script calls. + + This is particularly useful if you wish to import your own + libraries. + + :param js_file: Filename of JavaScript file to import. + + For example, Say you have a script, importfunc.js, that contains: + + :: + + let testFunc = function() { return "i'm a test function!";}; + + Assuming this file is in the same directory as the test, you + could do something like: + + :: + + js = os.path.abspath(os.path.join(__file__, os.path.pardir, "importfunc.js")) + marionette.import_script(js) + assert "i'm a test function!" == self.marionette.execute_script("return testFunc();") + """ + js = "" + with open(js_file, "r") as f: + js = f.read() + body = {"script": js} + self._send_message("importScript", body) + + def clear_imported_scripts(self): + """Clears all imported scripts in this context, ie: calling + clear_imported_scripts in chrome context will clear only scripts + you imported in chrome, and will leave the scripts you imported + in content context. + """ + self._send_message("clearImportedScripts") + + 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}) + """ + body = {"cookie": cookie} + self._send_message("addCookie", body) + + def delete_all_cookies(self): + """Delete all cookies in the scope of the current session. + + Usage example: + + :: + + driver.delete_all_cookies() + """ + self._send_message("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("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("getCookies", key="value" if self.protocol == 1 else None) + + def screenshot(self, element=None, highlights=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 highlights: A list of HTMLElement objects to draw a red + box around in the returned screenshot. + + :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 + lights = None + if highlights: + lights = [highlight.id for highlight in highlights] + + body = {"id": element, + "highlights": lights, + "full": full, + "hash": False, + "scroll": scroll} + if format == "hash": + body["hash"] = True + data = self._send_message("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. + """ + 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} + self._send_message("setScreenOrientation", body) + + @property + def window_size(self): + """Get the current browser window size. + + Will return the current browser window size in pixels. Refers to + window outerWidth and outerHeight values, which include scroll bars, + title bars, etc. + + :returns: dictionary representation of current window width and height + """ + return self._send_message("getWindowSize", + key="value" if self.protocol == 1 else None) + + def set_window_size(self, width, height): + """Resize the browser window currently in focus. + + 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 width: The width to resize the window to. + :param height: The height to resize the window to. + + """ + body = {"width": width, "height": height} + return self._send_message("setWindowSize", body) + + def maximize_window(self): + """ Resize the browser window currently receiving commands. The action + should be equivalent to the user pressing the the maximize button + """ + return self._send_message("maximizeWindow") diff --git a/testing/marionette/client/marionette_driver/selection.py b/testing/marionette/client/marionette_driver/selection.py new file mode 100644 index 000000000..30e66deaa --- /dev/null +++ b/testing/marionette/client/marionette_driver/selection.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +# 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 SelectionManager(object): + '''Interface for manipulating the selection and carets of the element. + + We call the blinking cursor (nsCaret) as cursor, and call AccessibleCaret as + caret for short. + + Simple usage example: + + :: + + element = marionette.find_element(By.ID, 'input') + sel = SelectionManager(element) + sel.move_caret_to_front() + + ''' + + def __init__(self, element): + self.element = element + + def _input_or_textarea(self): + '''Return True if element is either <input> or <textarea>.''' + return self.element.tag_name in ('input', 'textarea') + + def js_selection_cmd(self): + '''Return a command snippet to get selection object. + + If the element is <input> or <textarea>, return the selection object + associated with it. Otherwise, return the current selection object. + + Note: "element" must be provided as the first argument to + execute_script(). + + ''' + if self._input_or_textarea(): + # We must unwrap sel so that DOMRect could be returned to Python + # side. + return '''var sel = arguments[0].editor.selection;''' + else: + return '''var sel = window.getSelection();''' + + def move_cursor_by_offset(self, offset, backward=False): + '''Move cursor in the element by character offset. + + :param offset: Move the cursor to the direction by offset characters. + :param backward: Optional, True to move backward; Default to False to + move forward. + + ''' + cmd = self.js_selection_cmd() + ''' + for (let i = 0; i < {0}; ++i) {{ + sel.modify("move", "{1}", "character"); + }} + '''.format(offset, 'backward' if backward else 'forward') + + self.element.marionette.execute_script( + cmd, script_args=[self.element], sandbox='system') + + def move_cursor_to_front(self): + '''Move cursor in the element to the front of the content.''' + if self._input_or_textarea(): + cmd = '''arguments[0].setSelectionRange(0, 0);''' + else: + cmd = '''var sel = window.getSelection(); + sel.collapse(arguments[0].firstChild, 0);''' + + self.element.marionette.execute_script(cmd, script_args=[self.element]) + + def move_cursor_to_end(self): + '''Move cursor in the element to the end of the content.''' + if self._input_or_textarea(): + cmd = '''var len = arguments[0].value.length; + arguments[0].setSelectionRange(len, len);''' + else: + cmd = '''var sel = window.getSelection(); + sel.collapse(arguments[0].lastChild, arguments[0].lastChild.length);''' + + self.element.marionette.execute_script(cmd, script_args=[self.element]) + + def selection_rect_list(self, idx): + '''Return the selection's DOMRectList object for the range at given idx. + + If the element is either <input> or <textarea>, return the DOMRectList of + the range at given idx of the selection within the element. Otherwise, + return the DOMRectList of the of the range at given idx of current selection. + + ''' + cmd = self.js_selection_cmd() +\ + '''return sel.getRangeAt({}).getClientRects();'''.format(idx) + return self.element.marionette.execute_script(cmd, + script_args=[self.element], + sandbox='system') + + def range_count(self): + '''Get selection's range count''' + cmd = self.js_selection_cmd() +\ + '''return sel.rangeCount;''' + return self.element.marionette.execute_script(cmd, + script_args=[self.element], + sandbox='system') + + def _selection_location_helper(self, location_type): + '''Return the start and end location of the selection in the element. + + Return a tuple containing two pairs of (x, y) coordinates of the start + and end locations in the element. The coordinates are relative to the + top left-hand corner of the element. Both ltr and rtl directions are + considered. + + ''' + range_count = self.range_count() + first_rect_list = self.selection_rect_list(0) + last_rect_list = self.selection_rect_list(range_count - 1) + last_list_length = last_rect_list['length'] + first_rect, last_rect = first_rect_list['0'], last_rect_list[str(last_list_length - 1)] + origin_x, origin_y = self.element.rect['x'], self.element.rect['y'] + + if self.element.get_property('dir') == 'rtl': # such as Arabic + start_pos, end_pos = 'right', 'left' + else: + start_pos, end_pos = 'left', 'right' + + # Calculate y offset according to different needs. + if location_type == 'center': + start_y_offset = first_rect['height'] / 2.0 + end_y_offset = last_rect['height'] / 2.0 + elif location_type == 'caret': + # Selection carets' tip are below the bottom of the two ends of the + # selection. Add 5px to y should be sufficient to locate them. + caret_tip_y_offset = 5 + start_y_offset = first_rect['height'] + caret_tip_y_offset + end_y_offset = last_rect['height'] + caret_tip_y_offset + else: + start_y_offset = end_y_offset = 0 + + caret1_x = first_rect[start_pos] - origin_x + caret1_y = first_rect['top'] + start_y_offset - origin_y + caret2_x = last_rect[end_pos] - origin_x + caret2_y = last_rect['top'] + end_y_offset - origin_y + + return ((caret1_x, caret1_y), (caret2_x, caret2_y)) + + def selection_location(self): + '''Return the start and end location of the selection in the element. + + Return a tuple containing two pairs of (x, y) coordinates of the start + and end of the selection. The coordinates are relative to the top + left-hand corner of the element. Both ltr and rtl direction are + considered. + + ''' + return self._selection_location_helper('center') + + def carets_location(self): + '''Return a pair of the two carets' location. + + Return a tuple containing two pairs of (x, y) coordinates of the two + carets' tip. The coordinates are relative to the top left-hand corner of + the element. Both ltr and rtl direction are considered. + + ''' + return self._selection_location_helper('caret') + + def cursor_location(self): + '''Return the blanking cursor's center location within the element. + + Return (x, y) coordinates of the cursor's center relative to the top + left-hand corner of the element. + + ''' + return self._selection_location_helper('center')[0] + + def first_caret_location(self): + '''Return the first caret's location. + + Return (x, y) coordinates of the first caret's tip relative to the top + left-hand corner of the element. + + ''' + return self.carets_location()[0] + + def second_caret_location(self): + '''Return the second caret's location. + + Return (x, y) coordinates of the second caret's tip relative to the top + left-hand corner of the element. + + ''' + return self.carets_location()[1] + + def select_all(self): + '''Select all the content in the element.''' + if self._input_or_textarea(): + cmd = '''var len = arguments[0].value.length; + arguments[0].focus(); + arguments[0].setSelectionRange(0, len);''' + else: + cmd = '''var range = document.createRange(); + range.setStart(arguments[0].firstChild, 0); + range.setEnd(arguments[0].lastChild, arguments[0].lastChild.length); + var sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range);''' + + self.element.marionette.execute_script(cmd, script_args=[self.element]) + + @property + def content(self): + '''Return all the content of the element.''' + if self._input_or_textarea(): + return self.element.get_property('value') + else: + return self.element.text + + @property + def selected_content(self): + '''Return the selected portion of the content in the element.''' + cmd = self.js_selection_cmd() +\ + '''return sel.toString();''' + return self.element.marionette.execute_script(cmd, + script_args=[self.element], + sandbox='system') diff --git a/testing/marionette/client/marionette_driver/timeout.py b/testing/marionette/client/marionette_driver/timeout.py new file mode 100644 index 000000000..e2fa94f4f --- /dev/null +++ b/testing/marionette/client/marionette_driver/timeout.py @@ -0,0 +1,98 @@ +# 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 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 + try: + self._marionette._send_message("setTimeouts", {name: ms}) + except errors.UnknownCommandException: + # remove when 55 is stable + self._marionette._send_message("timeouts", {"type": name, "ms": ms}) + + def _get(self, name): + ms = self._marionette._send_message("getTimeouts", key=name) + return ms / 1000 + + @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). + + """ + 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. + + """ + 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 000000000..82828fdef --- /dev/null +++ b/testing/marionette/client/marionette_driver/transport.py @@ -0,0 +1,300 @@ +# 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 time + + +class SocketTimeout(object): + def __init__(self, socket, timeout): + self.sock = socket + self.timeout = timeout + self.old_timeout = None + + def __enter__(self): + self.old_timeout = self.sock.gettimeout() + self.sock.settimeout(self.timeout) + + def __exit__(self, *args, **kwargs): + self.sock.settimeout(self.old_timeout) + + +class Message(object): + def __init__(self, msgid): + self.id = msgid + + def __eq__(self, other): + return self.id == other.id + + def __ne__(self, other): + return not self.__eq__(other) + + +class Command(Message): + TYPE = 0 + + def __init__(self, msgid, name, params): + Message.__init__(self, msgid) + self.name = name + self.params = params + + def __str__(self): + return "<Command id={0}, name={1}, params={2}>".format(self.id, self.name, self.params) + + def to_msg(self): + msg = [Command.TYPE, self.id, self.name, self.params] + return json.dumps(msg) + + @staticmethod + def from_msg(payload): + data = json.loads(payload) + assert data[0] == Command.TYPE + cmd = Command(data[1], data[2], data[3]) + return cmd + + +class Response(Message): + TYPE = 1 + + def __init__(self, msgid, error, result): + Message.__init__(self, msgid) + self.error = error + self.result = result + + def __str__(self): + return "<Response id={0}, error={1}, result={2}>".format(self.id, self.error, self.result) + + def to_msg(self): + msg = [Response.TYPE, self.id, self.error, self.result] + return json.dumps(msg) + + @staticmethod + def from_msg(payload): + data = json.loads(payload) + assert data[0] == Response.TYPE + return Response(data[1], data[2], data[3]) + + +class Proto2Command(Command): + """Compatibility shim that marshals messages from a protocol level + 2 and below remote into ``Command`` objects. + """ + + def __init__(self, name, params): + Command.__init__(self, None, name, params) + + +class Proto2Response(Response): + """Compatibility shim that marshals messages from a protocol level + 2 and below remote into ``Response`` objects. + """ + + def __init__(self, error, result): + Response.__init__(self, None, error, result) + + @staticmethod + def from_data(data): + err, res = None, None + if "error" in data: + err = data + else: + res = data + return Proto2Response(err, res) + + +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 1 and above. + """ + max_packet_length = 4096 + + def __init__(self, addr, 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.addr = addr + self.port = port + self._socket_timeout = socket_timeout + + self.protocol = 1 + self.application_type = None + self.last_id = 0 + self.expected_response = None + self.sock = None + + @property + def socket_timeout(self): + return self._socket_timeout + + @socket_timeout.setter + def socket_timeout(self, value): + if self.sock: + self.sock.settimeout(value) + self._socket_timeout = value + + def _unmarshal(self, packet): + msg = None + + # protocol 3 and above + if self.protocol >= 3: + typ = int(packet[1]) + if typ == Command.TYPE: + msg = Command.from_msg(packet) + elif typ == Response.TYPE: + msg = Response.from_msg(packet) + + # protocol 2 and below + else: + data = json.loads(packet) + + msg = Proto2Response.from_data(data) + + return msg + + def receive(self, unmarshal=True): + """Wait for the next complete response from the remote. + + :param unmarshal: Default is to deserialise the packet and + return a ``Message`` type. Setting this to false will return + the raw packet. + """ + now = time.time() + data = "" + bytes_to_recv = 10 + + while self.socket_timeout is None or (time.time() - now < self.socket_timeout): + try: + chunk = self.sock.recv(bytes_to_recv) + data += chunk + except socket.timeout: + pass + else: + if not chunk: + raise socket.error("No data received over socket") + + sep = data.find(":") + if sep > -1: + length = data[0:sep] + remaining = data[sep + 1:] + + if len(remaining) == int(length): + if unmarshal: + msg = self._unmarshal(remaining) + self.last_id = msg.id + + if self.protocol >= 3: + self.last_id = msg.id + + # keep reading incoming responses until + # we receive the user's expected response + if isinstance(msg, Response) and msg != self.expected_response: + return self.receive(unmarshal) + + return msg + + else: + return remaining + + bytes_to_recv = int(length) - len(remaining) + + raise socket.timeout("Connection timed out after {}s".format(self.socket_timeout)) + + def connect(self): + """Connect to the server and process the hello message we expect + to receive in response. + + Returns a tuple of the protocol level and the application type. + """ + try: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.settimeout(self.socket_timeout) + + self.sock.connect((self.addr, self.port)) + except: + # Unset self.sock so that the next attempt to send will cause + # another connection attempt. + self.sock = None + raise + + with SocketTimeout(self.sock, 2.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) + hello = json.loads(raw) + self.protocol = hello.get("marionetteProtocol", 1) + self.application_type = hello.get("applicationType") + + return (self.protocol, self.application_type) + + def send(self, obj): + """Send message to the remote server. Allowed input is a + ``Message`` instance or a JSON serialisable object. + """ + if not self.sock: + self.connect() + + if isinstance(obj, Message): + data = obj.to_msg() + if isinstance(obj, Command): + self.expected_response = obj + else: + data = json.dumps(obj) + payload = "{0}:{1}".format(len(data), data) + + totalsent = 0 + while totalsent < len(payload): + sent = self.sock.send(payload[totalsent:]) + if sent == 0: + raise IOError("Socket error after sending {0} of {1} bytes" + .format(totalsent, len(payload))) + else: + totalsent += sent + + def respond(self, obj): + """Send a response to a command. This can be an arbitrary JSON + serialisable object or an ``Exception``. + """ + res, err = None, None + if isinstance(obj, Exception): + err = obj + else: + res = obj + msg = Response(self.last_id, err, res) + self.send(msg) + return self.receive() + + def request(self, name, params): + """Sends a message to the remote server and waits for a response + to come back. + """ + self.last_id = self.last_id + 1 + cmd = Command(self.last_id, name, params) + self.send(cmd) + return self.receive() + + def close(self): + """Close the socket.""" + if self.sock: + try: + self.sock.shutdown(socket.SHUT_RDWR) + except IOError as exc: + # Errno 57 is "socket not connected", which we don't care about here. + if exc.errno != 57: + raise + + self.sock.close() + self.sock = None + + def __del__(self): + self.close() diff --git a/testing/marionette/client/marionette_driver/wait.py b/testing/marionette/client/marionette_driver/wait.py new file mode 100644 index 000000000..c89465ce4 --- /dev/null +++ b/testing/marionette/client/marionette_driver/wait.py @@ -0,0 +1,167 @@ +# 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 errors +import sys +import time + +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=DEFAULT_TIMEOUT, + interval=DEFAULT_INTERVAL, 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 + self.clock = clock or SystemClock() + self.end = self.clock.now + self.timeout + self.interval = interval + + exceptions = [] + if ignored_exceptions is not None: + if isinstance(ignored_exceptions, collections.Iterable): + exceptions.extend(iter(ignored_exceptions)) + else: + exceptions.append(ignored_exceptions) + self.exceptions = tuple(set(exceptions)) + + def until(self, condition, is_true=None, message=""): + """Repeatedly runs condition until its return value evaluates to true, + or its timeout expires or the predicate evaluates to true. + + This will poll at the given interval until the given timeout + is reached, or the predicate or conditions returns true. A + condition that returns null or does not evaluate to true will + fully elapse its timeout before raising an + `errors.TimeoutException`. + + If an exception is raised in the condition function and it's + not ignored, this function will raise immediately. If the + exception is ignored, it will continue polling for the + condition until it returns successfully or a + `TimeoutException` is raised. + + :param condition: A callable function whose return value will + be returned by this function if it evaluates to true. + + :param is_true: An optional predicate that will terminate and + return when it evaluates to False. It should be a + function that will be passed clock and an end time. The + default predicate will terminate a wait when the clock + elapses the timeout. + + :param message: An optional message to include in the + exception's message if this function times out. + + """ + + rv = None + last_exc = None + until = is_true or until_pred + start = self.clock.now + + while not until(self.clock, self.end): + try: + next = self.clock.now + self.interval + rv = condition(self.marionette) + except (KeyboardInterrupt, SystemExit): + raise + except self.exceptions: + last_exc = sys.exc_info() + + # Re-adjust the interval depending on how long the callback + # took to evaluate the condition + interval_new = max(next - self.clock.now, 0) + + if not rv: + self.clock.sleep(interval_new) + continue + + if rv is not None: + return rv + + self.clock.sleep(interval_new) + + if message: + message = " with message: {}".format(message) + + raise errors.TimeoutException( + "Timed out after {0} seconds{1}".format(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 000000000..a06a719ac --- /dev/null +++ b/testing/marionette/client/requirements.txt @@ -0,0 +1,2 @@ +mozrunner >= 6.13 +mozversion >= 1.1 diff --git a/testing/marionette/client/setup.py b/testing/marionette/client/setup.py new file mode 100644 index 000000000..b73b17d08 --- /dev/null +++ b/testing/marionette/client/setup.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/. + +import os +import re +from setuptools import setup, find_packages + +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 http://marionette-client.readthedocs.org/en/latest/', + # 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='tools-marionette@lists.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(), + ) |