diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /testing/marionette | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/marionette')
220 files changed, 24370 insertions, 0 deletions
diff --git a/testing/marionette/client/MANIFEST.in b/testing/marionette/client/MANIFEST.in new file mode 100644 index 0000000000..cf628b039c --- /dev/null +++ b/testing/marionette/client/MANIFEST.in @@ -0,0 +1,2 @@ +exclude MANIFEST.in +include requirements.txt diff --git a/testing/marionette/client/docs/Makefile b/testing/marionette/client/docs/Makefile new file mode 100644 index 0000000000..f3d89d6d47 --- /dev/null +++ b/testing/marionette/client/docs/Makefile @@ -0,0 +1,153 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make <target>' where <target> is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + -rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/MarionettePythonClient.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/MarionettePythonClient.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/MarionettePythonClient" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/MarionettePythonClient" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." diff --git a/testing/marionette/client/docs/advanced/actions.rst b/testing/marionette/client/docs/advanced/actions.rst new file mode 100644 index 0000000000..c767bdecdc --- /dev/null +++ b/testing/marionette/client/docs/advanced/actions.rst @@ -0,0 +1,21 @@ +Actions +======= + +.. py:currentmodule:: marionette_driver.marionette + +Action Sequences +---------------- + +:class:`Actions` are designed as a way to simulate user input like a keyboard +or a pointer device as closely as possible. For multiple interactions an +action sequence can be used:: + + element = marionette.find_element("id", "input") + element.click() + + key_chain = self.marionette.actions.sequence("key", "keyboard1") + key_chain.send_keys("fooba").pause(100).key_down("r").perform() + +This will simulate entering "fooba" into the input field, waiting for 100ms, +and pressing the key "r". The pause is optional in this case, but can be useful +for simulating delays typical to a users behaviour. diff --git a/testing/marionette/client/docs/advanced/debug.rst b/testing/marionette/client/docs/advanced/debug.rst new file mode 100644 index 0000000000..895009ef7f --- /dev/null +++ b/testing/marionette/client/docs/advanced/debug.rst @@ -0,0 +1,35 @@ +Debugging +========= + +.. py:currentmodule:: marionette_driver.marionette + +Sometimes when working with Marionette you'll run into unexpected behaviour and +need to do some debugging. This page outlines some of the Marionette methods +that can be useful to you. + +Please note that the best tools for debugging are the `ones that ship with +Gecko`_. This page doesn't describe how to use those with Marionette. Also see +a related topic about `using the debugger with Marionette`_ on MDN. + +.. _ones that ship with Gecko: https://developer.mozilla.org/en-US/docs/Tools +.. _using the debugger with Marionette: https://developer.mozilla.org/en-US/docs/Marionette/Debugging + +Seeing What's on the Page +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Sometimes it's difficult to tell what is actually on the page that is being +manipulated. Either because it happens too fast, the window isn't big enough or +you are manipulating a remote server! There are two methods that can help you +out. The first is :func:`~Marionette.screenshot`:: + + marionette.screenshot() # takes screenshot of entire frame + elem = marionette.find_element(By.ID, 'some-div') + marionette.screenshot(elem) # takes a screenshot of only the given element + +Sometimes you just want to see the DOM layout. You can do this with the +:attr:`~Marionette.page_source` property. Note that the page source depends on +the context you are in:: + + print(marionette.page_source) + marionette.set_context('chrome') + print(marionette.page_source) diff --git a/testing/marionette/client/docs/advanced/findelement.rst b/testing/marionette/client/docs/advanced/findelement.rst new file mode 100644 index 0000000000..9d4a34b052 --- /dev/null +++ b/testing/marionette/client/docs/advanced/findelement.rst @@ -0,0 +1,87 @@ +Finding Elements +================ +.. py:currentmodule:: marionette_driver.marionette + +One of the most common and yet often most difficult tasks in Marionette is +finding a DOM element on a webpage or in the chrome UI. Marionette provides +several different search strategies to use when finding elements. All search +strategies work with both :func:`~Marionette.find_element` and +:func:`~Marionette.find_elements`, though some strategies are not implemented +in chrome scope. + +In the event that more than one element is matched by the query, +:func:`~Marionette.find_element` will only return the first element found. In +the event that no elements are matched by the query, +:func:`~Marionette.find_element` will raise `NoSuchElementException` while +:func:`~Marionette.find_elements` will return an empty list. + +Search Strategies +----------------- + +Search strategies are defined in the :class:`By` class:: + + from marionette_driver import By + print(By.ID) + +The strategies are: + +* `id` - The easiest way to find an element is to refer to its id directly:: + + container = client.find_element(By.ID, 'container') + +* `class name` - To find elements belonging to a certain class, use `class name`:: + + buttons = client.find_elements(By.CLASS_NAME, 'button') + +* `css selector` - It's also possible to find elements using a `css selector`_:: + + container_buttons = client.find_elements(By.CSS_SELECTOR, '#container .buttons') + +* `name` - Find elements by their name attribute (not implemented in chrome + scope):: + + form = client.find_element(By.NAME, 'signup') + +* `tag name` - To find all the elements with a given tag, use `tag name`:: + + paragraphs = client.find_elements(By.TAG_NAME, 'p') + +* `link text` - A convenience strategy for finding link elements by their + innerHTML (not implemented in chrome scope):: + + link = client.find_element(By.LINK_TEXT, 'Click me!') + +* `partial link text` - Same as `link text` except substrings of the innerHTML + are matched (not implemented in chrome scope):: + + link = client.find_element(By.PARTIAL_LINK_TEXT, 'Clic') + +* `xpath` - Find elements using an xpath_ query:: + + elem = client.find_element(By.XPATH, './/*[@id="foobar"') + +.. _css selector: https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Getting_Started/Selectors +.. _xpath: https://developer.mozilla.org/en-US/docs/Web/XPath + + + +Chaining Searches +----------------- + +In addition to the methods on the Marionette object, WebElement objects also +provide :func:`~WebElement.find_element` and :func:`~WebElement.find_elements` +methods. The difference is that only child nodes of the element will be searched. +Consider the following html snippet:: + + <div id="content"> + <span id="main"></span> + </div> + <div id="footer"></div> + +Doing the following will work:: + + client.find_element(By.ID, 'container').find_element(By.ID, 'main') + +But this will raise a `NoSuchElementException`:: + + client.find_element(By.ID, 'container').find_element(By.ID, 'footer') diff --git a/testing/marionette/client/docs/advanced/landing.rst b/testing/marionette/client/docs/advanced/landing.rst new file mode 100644 index 0000000000..0a44de63d7 --- /dev/null +++ b/testing/marionette/client/docs/advanced/landing.rst @@ -0,0 +1,13 @@ +Advanced Topics +=============== + +Here are a collection of articles explaining some of the more complicated +aspects of Marionette. + +.. toctree:: + :maxdepth: 1 + + findelement + stale + actions + debug diff --git a/testing/marionette/client/docs/advanced/stale.rst b/testing/marionette/client/docs/advanced/stale.rst new file mode 100644 index 0000000000..f90ab63579 --- /dev/null +++ b/testing/marionette/client/docs/advanced/stale.rst @@ -0,0 +1,76 @@ +Dealing with Stale Elements +=========================== +.. py:currentmodule:: marionette_driver.marionette + +Marionette does not keep a live representation of the DOM saved. All it can do +is send commands to the Marionette server which queries the DOM on the client's +behalf. References to elements are also not passed from server to client. A +unique id is generated for each element that gets referenced and a mapping of +id to element object is stored on the server. When commands such as +:func:`~WebElement.click` are run, the client sends the element's id along +with the command. The server looks up the proper DOM element in its reference +table and executes the command on it. + +In practice this means that the DOM can change state and Marionette will never +know until it sends another query. For example, look at the following HTML:: + + <head> + <script type=text/javascript> + function addDiv() { + var div = document.createElement("div"); + document.getElementById("container").appendChild(div); + } + </script> + </head> + + <body> + <div id="container"> + </div> + <input id="button" type=button onclick="addDiv();"> + </body> + +Care needs to be taken as the DOM is being modified after the page has loaded. +The following code has a race condition:: + + button = client.find_element('id', 'button') + button.click() + assert len(client.find_elements('css selector', '#container div')) > 0 + + +Explicit Waiting and Expected Conditions +---------------------------------------- +.. py:currentmodule:: marionette_driver + +To avoid the above scenario, manual synchronisation is needed. Waits are used +to pause program execution until a given condition is true. This is a useful +technique to employ when documents load new content or change after +``Document.readyState``'s value changes to "complete". + +The :class:`Wait` helper class provided by Marionette avoids some of the +caveats of ``time.sleep(n)``. It will return immediately once the provided +condition evaluates to true. + +To avoid the race condition in the above example, one could do:: + + from marionette_driver import Wait + + button = client.find_element('id', 'button') + button.click() + + def find_divs(): + return client.find_elements('css selector', '#container div') + + divs = Wait(client).until(find_divs) + assert len(divs) > 0 + +This avoids the race condition. Because finding elements is a common condition +to wait for, it is built in to Marionette. Instead of the above, you could +write:: + + from marionette_driver import Wait + + button = client.find_element('id', 'button') + button.click() + assert len(Wait(client).until(expected.elements_present('css selector', '#container div'))) > 0 + +For a full list of built-in conditions, see :mod:`~marionette_driver.expected`. diff --git a/testing/marionette/client/docs/basics.rst b/testing/marionette/client/docs/basics.rst new file mode 100644 index 0000000000..76ae71015b --- /dev/null +++ b/testing/marionette/client/docs/basics.rst @@ -0,0 +1,195 @@ +.. 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: + +.. code-block:: bash + + $ 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>`: + +.. code-block:: python + + from marionette_driver.marionette import Marionette + + client = Marionette('127.0.0.1', port=2828) + client.start_session() + +This returns a session id and an object listing the capabilities of the +Marionette server. For example, a server running on Firefox Desktop will +have some features which a server running from Firefox Android won't. +It's also possible to access the capabilities using the +:attr:`~Marionette.session_capabilities` attribute. After finishing with a +session, you can delete it with :func:`~Marionette.delete_session()`. Note that +this will also happen automatically when the Marionette object is garbage +collected. + +Context Management +------------------ +Commands can only be executed in a single window, frame and scope at a time. In +order to run commands elsewhere, it's necessary to explicitly switch to the +appropriate context. + +Use :func:`~Marionette.switch_to_window` to execute commands in the context of a +new window: + +.. code-block:: python + + 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): + +.. code-block:: python + + iframe = client.find_element(By.TAG_NAME, 'iframe') + client.switch_to_frame(iframe) + +Finally Marionette can switch between `chrome` and `content` scope. Chrome is a +privileged scope where you can access things like the Firefox UI itself. +Content scope is where things like webpages live. You can switch between +`chrome` and `content` using the :func:`~Marionette.set_context` and :func:`~Marionette.using_context` functions: + +.. code-block:: python + + client.set_context(client.CONTEXT_CONTENT) + # content scope + with client.using_context(client.CONTEXT_CHROME): + #chrome scope + pass # ... 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`: + +.. code-block:: python + + 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: + +.. code-block:: python + + from marionette_driver.marionette import WebElement + element = client.find_element(By.ID, 'my-id') + assert type(element) == WebElement + 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: + +.. code-block:: python + + element.click() + element.send_keys('hello!') + print(element.get_attribute('style')) + +For the full list of possible commands, see the :class:`WebElement` +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: + +.. code-block:: python + + result = client.execute_script("return arguments[0] + arguments[1];", + script_args=[2, 3]) + assert result == 5 + +The async method works the same way, except it won't return until the +`resolve()` function is called: + +.. code-block:: python + + result = client.execute_async_script(""" + let [resolve] = arguments; + setTimeout(function() { + resolve("all done"); + }, arguments[0]); + """, script_args=[1000]) + assert result == "all done" + +Beware that running asynchronous scripts can potentially hang the program +indefinitely if they are not written properly. It is generally a good idea to +set a script timeout using :func:`~Marionette.timeout.script` and handling +`ScriptTimeoutException`. diff --git a/testing/marionette/client/docs/conf.py b/testing/marionette/client/docs/conf.py new file mode 100644 index 0000000000..692545faa9 --- /dev/null +++ b/testing/marionette/client/docs/conf.py @@ -0,0 +1,274 @@ +# -*- coding: utf-8 -*- +# +# Marionette Python Client documentation build configuration file, created by +# sphinx-quickstart on Tue Aug 6 13:54:46 2013. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import os +import sys + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# sys.path.insert(0, os.path.abspath('.')) + +here = os.path.dirname(os.path.abspath(__file__)) +parent = os.path.dirname(here) +sys.path.insert(0, parent) + +# -- General configuration ----------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = ["sphinx.ext.autodoc"] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# The suffix of source filenames. +source_suffix = ".rst" + +# The encoding of source files. +# source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = "index" + +# General information about the project. +project = "Marionette Python Client" +copyright = "2013, Mozilla Automation and Tools and individual contributors" + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +# version = '0' +# The full version, including alpha/beta/rc tags. +# release = '0' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +# today = '' +# Else, today_fmt is used as the format for a strftime call. +# today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ["_build"] + +# The reST default role (used for this markup: `text`) to use for all documents. +# default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +# add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +# add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +# show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + +# A list of ignored prefixes for module index sorting. +# modindex_common_prefix = [] + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. + +html_theme = "default" + +on_rtd = os.environ.get("READTHEDOCS", None) == "True" + +if not on_rtd: + try: + import sphinx_rtd_theme + + html_theme = "sphinx_rtd_theme" + html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + except ImportError: + pass + + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +# html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# "<project> v<release> documentation". +# html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +# html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +# html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +# html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +# html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +# html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# html_additional_pages = {} + +# If false, no module index is generated. +# html_domain_indices = True + +# If false, no index is generated. +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# html_split_index = False + +# If true, links to the reST sources are added to the pages. +# html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +# html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +html_show_copyright = False + +# If true, an OpenSearch description file will be output, and all pages will +# contain a <link> tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +# html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = "MarionettePythonClientdoc" + + +# -- Options for LaTeX output -------------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # 'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ( + "index", + "MarionettePythonClient.tex", + "Marionette Python Client Documentation", + "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", + "Marionette Python Client Documentation", + ["Mozilla Automation and Tools team"], + 1, + ) +] + +# If true, show URL addresses after external links. +# man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------------ + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ( + "index", + "MarionettePythonClient", + "Marionette Python Client Documentation", + "Mozilla Automation and Tools team", + "MarionettePythonClient", + "One line description of project.", + "Miscellaneous", + ), +] + +# Documents to append as an appendix to all manuals. +# texinfo_appendices = [] + +# If false, no module index is generated. +# texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +# texinfo_show_urls = 'footnote' diff --git a/testing/marionette/client/docs/index.rst b/testing/marionette/client/docs/index.rst new file mode 100644 index 0000000000..b1f266726c --- /dev/null +++ b/testing/marionette/client/docs/index.rst @@ -0,0 +1,16 @@ +.. include:: basics.rst + +Indices and tables +------------------ + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + +.. toctree:: + :hidden: + + Getting Started <basics> + Interactive Tutorial <interactive> + advanced/landing + reference diff --git a/testing/marionette/client/docs/interactive.rst b/testing/marionette/client/docs/interactive.rst new file mode 100644 index 0000000000..7b2ebe2ec3 --- /dev/null +++ b/testing/marionette/client/docs/interactive.rst @@ -0,0 +1,52 @@ +Using the Client Interactively +============================== + +Once you installed the client and have Marionette running, you can fire +up your favourite interactive python environment and start playing with +Marionette. Let's use a typical python shell: + +.. parsed-literal:: + python + +First, import Marionette: + +.. parsed-literal:: + from marionette_driver.marionette import Marionette + +Now create the client for this session. Assuming you're using the default +port on a Marionette instance running locally: + +.. parsed-literal:: + client = Marionette(host='127.0.0.1', port=2828) + client.start_session() + +This will return some id representing your session id. Now that you've +established a connection, let's start doing interesting things: + +.. parsed-literal:: + client.navigate("http://www.mozilla.org") + +Now you're at mozilla.org! You can even verify it using the following: + +.. parsed-literal:: + client.get_url() + +You can execute Javascript code in the scope of the web page: + +.. parsed-literal:: + client.execute_script("return window.document.title;") + +This will you return the title of the web page as set in the head section +of the HTML document. + +Also you can find elements and click on those. Let's say you want to get +the first link: + +.. parsed-literal:: + from marionette_driver import By + first_link = client.find_element(By.TAG_NAME, "a") + +first_link now holds a reference to the first link on the page. You can click it: + +.. parsed-literal:: + first_link.click() diff --git a/testing/marionette/client/docs/make.bat b/testing/marionette/client/docs/make.bat new file mode 100644 index 0000000000..fb02fc1a8c --- /dev/null +++ b/testing/marionette/client/docs/make.bat @@ -0,0 +1,190 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^<target^>` where ^<target^> is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\MarionettePythonClient.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\MarionettePythonClient.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +:end diff --git a/testing/marionette/client/docs/reference.rst b/testing/marionette/client/docs/reference.rst new file mode 100644 index 0000000000..ed4a7ce109 --- /dev/null +++ b/testing/marionette/client/docs/reference.rst @@ -0,0 +1,66 @@ +============= +API Reference +============= + +Marionette +---------- +.. py:currentmodule:: marionette_driver.marionette.Marionette +.. autoclass:: marionette_driver.marionette.Marionette + :members: + +WebElement +----------- +.. py:currentmodule:: marionette_driver.marionette.WebElement +.. autoclass:: marionette_driver.marionette.WebElement + :members: + +DateTimeValue +------------- +.. py:currentmodule:: marionette_driver.DateTimeValue +.. autoclass:: marionette_driver.DateTimeValue + :members: + +Actions +------- +.. py:currentmodule:: marionette_driver.marionette.Actions +.. autoclass:: marionette_driver.marionette.Actions + :members: + +Alert +----- +.. py:currentmodule:: marionette_driver.marionette.Alert +.. autoclass:: marionette_driver.marionette.Alert + :members: + +Wait +---- +.. py:currentmodule:: marionette_driver.Wait +.. autoclass:: marionette_driver.Wait + :members: + :special-members: +.. autoattribute marionette_driver.wait.DEFAULT_TIMEOUT +.. autoattribute marionette_driver.wait.DEFAULT_INTERVAL + +Built-in Conditions +^^^^^^^^^^^^^^^^^^^ +.. py:currentmodule:: marionette_driver.expected +.. automodule:: marionette_driver.expected + :members: + +Timeouts +-------- +.. py:currentmodule:: marionette_driver.timeout.Timeouts +.. autoclass:: marionette_driver.timeout.Timeouts + :members: + +Addons +------ +.. py:currentmodule:: marionette_driver.addons.Addons +.. autoclass:: marionette_driver.addons.Addons + :members: + +Localization +------------ +.. py:currentmodule:: marionette_driver.localization.L10n +.. autoclass:: marionette_driver.localization.L10n + :members: diff --git a/testing/marionette/client/marionette_driver/__init__.py b/testing/marionette/client/marionette_driver/__init__.py new file mode 100644 index 0000000000..443fbd8fc3 --- /dev/null +++ b/testing/marionette/client/marionette_driver/__init__.py @@ -0,0 +1,22 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +__version__ = "3.4.0" + +from marionette_driver import ( + addons, + by, + date_time_value, + decorators, + errors, + expected, + geckoinstance, + keys, + localization, + marionette, + wait, +) +from marionette_driver.by import By +from marionette_driver.date_time_value import DateTimeValue +from marionette_driver.wait import Wait diff --git a/testing/marionette/client/marionette_driver/addons.py b/testing/marionette/client/marionette_driver/addons.py new file mode 100644 index 0000000000..09f44e3e54 --- /dev/null +++ b/testing/marionette/client/marionette_driver/addons.py @@ -0,0 +1,76 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import os + +from . import errors + +__all__ = ["Addons", "AddonInstallException"] + + +class AddonInstallException(errors.MarionetteException): + pass + + +class Addons(object): + """An API for installing and inspecting addons during Gecko + runtime. This is a partially implemented wrapper around Gecko's + `AddonManager API`_. + + For example:: + + from marionette_driver.addons import Addons + addons = Addons(marionette) + addons.install("/path/to/extension.xpi") + + .. _AddonManager API: https://developer.mozilla.org/en-US/Add-ons/Add-on_Manager + + """ + + def __init__(self, marionette): + self._mn = marionette + + def install(self, path, temp=False): + """Install a Firefox addon. + + If the addon is restartless, it can be used right away. Otherwise + a restart using :func:`~marionette_driver.marionette.Marionette.restart` + will be needed. + + :param path: A file path to the extension to be installed. + :param temp: Install a temporary addon. Temporary addons will + automatically be uninstalled on shutdown and do not need + to be signed, though they must be restartless. + + :returns: The addon ID string of the newly installed addon. + + :raises: :exc:`AddonInstallException` + + """ + # On windows we can end up with a path with mixed \ and / + # which Firefox doesn't like + path = path.replace("/", os.path.sep) + + body = {"path": path, "temporary": temp} + try: + return self._mn._send_message("Addon:Install", body, key="value") + except errors.UnknownException as e: + raise AddonInstallException(e) + + def uninstall(self, addon_id): + """Uninstall a Firefox addon. + + If the addon is restartless, it will be uninstalled right away. + Otherwise a restart using :func:`~marionette_driver.marionette.Marionette.restart` + will be needed. + + If the call to uninstall is resulting in a `ScriptTimeoutException`, + an invalid ID is likely being passed in. Unfortunately due to + AddonManager's implementation, it's hard to retrieve this error from + Python. + + :param addon_id: The addon ID string to uninstall. + + """ + self._mn._send_message("Addon:Uninstall", {"id": addon_id}) diff --git a/testing/marionette/client/marionette_driver/by.py b/testing/marionette/client/marionette_driver/by.py new file mode 100644 index 0000000000..b54ca729f2 --- /dev/null +++ b/testing/marionette/client/marionette_driver/by.py @@ -0,0 +1,25 @@ +# Copyright 2008-2009 WebDriver committers +# Copyright 2008-2009 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class By(object): + ID = "id" + XPATH = "xpath" + LINK_TEXT = "link text" + PARTIAL_LINK_TEXT = "partial link text" + NAME = "name" + TAG_NAME = "tag name" + CLASS_NAME = "class name" + CSS_SELECTOR = "css selector" diff --git a/testing/marionette/client/marionette_driver/date_time_value.py b/testing/marionette/client/marionette_driver/date_time_value.py new file mode 100644 index 0000000000..c6f2ed989a --- /dev/null +++ b/testing/marionette/client/marionette_driver/date_time_value.py @@ -0,0 +1,49 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + + +class DateTimeValue(object): + """ + Interface for setting the value of HTML5 "date" and "time" input elements. + + Simple usage example: + + :: + + element = marionette.find_element(By.ID, "date-test") + dt_value = DateTimeValue(element) + dt_value.date = datetime(1998, 6, 2) + + """ + + def __init__(self, element): + self.element = element + + @property + def date(self): + """ + Retrieve the element's string value + """ + return self.element.get_attribute("value") + + # As per the W3C "date" element specification + # (http://dev.w3.org/html5/markup/input.date.html), this value is formatted + # according to RFC 3339: http://tools.ietf.org/html/rfc3339#section-5.6 + @date.setter + def date(self, date_value): + self.element.send_keys(date_value.strftime("%Y-%m-%d")) + + @property + def time(self): + """ + Retrieve the element's string value + """ + return self.element.get_attribute("value") + + # As per the W3C "time" element specification + # (http://dev.w3.org/html5/markup/input.time.html), this value is formatted + # according to RFC 3339: http://tools.ietf.org/html/rfc3339#section-5.6 + @time.setter + def time(self, time_value): + self.element.send_keys(time_value.strftime("%H:%M:%S")) diff --git a/testing/marionette/client/marionette_driver/decorators.py b/testing/marionette/client/marionette_driver/decorators.py new file mode 100644 index 0000000000..95a5c5bbee --- /dev/null +++ b/testing/marionette/client/marionette_driver/decorators.py @@ -0,0 +1,79 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import socket +from functools import wraps + + +def _find_marionette_in_args(*args, **kwargs): + try: + m = [a for a in args + tuple(kwargs.values()) if hasattr(a, "session")][0] + except IndexError: + print("Can only apply decorator to function using a marionette object") + raise + return m + + +def do_process_check(func): + """Decorator which checks the process status after the function has run.""" + + @wraps(func) + def _(*args, **kwargs): + try: + return func(*args, **kwargs) + except (socket.error, socket.timeout): + m = _find_marionette_in_args(*args, **kwargs) + + # In case of socket failures which will also include crashes of the + # application, make sure to handle those correctly. In case of an + # active shutdown just let it bubble up. + if m.is_shutting_down: + raise + + m._handle_socket_failure() + + return _ + + +def uses_marionette(func): + """Decorator which creates a marionette session and deletes it + afterwards if one doesn't already exist. + """ + + @wraps(func) + def _(*args, **kwargs): + m = _find_marionette_in_args(*args, **kwargs) + delete_session = False + if not m.session: + delete_session = True + m.start_session() + + m.set_context(m.CONTEXT_CHROME) + ret = func(*args, **kwargs) + + if delete_session: + m.delete_session() + + return ret + + return _ + + +def using_context(context): + """Decorator which allows a function to execute in certain scope + using marionette.using_context functionality and returns to old + scope once the function exits. + :param context: Either 'chrome' or 'content'. + """ + + def wrap(func): + @wraps(func) + def inner(*args, **kwargs): + m = _find_marionette_in_args(*args, **kwargs) + with m.using_context(context): + return func(*args, **kwargs) + + return inner + + return wrap diff --git a/testing/marionette/client/marionette_driver/errors.py b/testing/marionette/client/marionette_driver/errors.py new file mode 100644 index 0000000000..27e1928a73 --- /dev/null +++ b/testing/marionette/client/marionette_driver/errors.py @@ -0,0 +1,206 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import traceback + +import six + + +@six.python_2_unicode_compatible +class MarionetteException(Exception): + + """Raised when a generic non-recoverable exception has occured.""" + + status = "webdriver error" + + def __init__(self, message=None, cause=None, stacktrace=None): + """Construct new MarionetteException instance. + + :param message: An optional exception message. + + :param cause: An optional tuple of three values giving + information about the root exception cause. Expected + tuple values are (type, value, traceback). + + :param stacktrace: Optional string containing a stacktrace + (typically from a failed JavaScript execution) that will + be displayed in the exception's string representation. + + """ + self.cause = cause + self.stacktrace = stacktrace + self._message = six.text_type(message) + + def __str__(self): + # pylint: disable=W1645 + msg = self.message + tb = None + + if self.cause: + if type(self.cause) is tuple: + msg += ", 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 six.text_type(msg) + + @property + def message(self): + return self._message + + +class DetachedShadowRootException(MarionetteException): + status = "detached shadow root" + + +class ElementNotSelectableException(MarionetteException): + status = "element not selectable" + + +class ElementClickInterceptedException(MarionetteException): + status = "element click intercepted" + + +class InsecureCertificateException(MarionetteException): + status = "insecure certificate" + + +class InvalidArgumentException(MarionetteException): + status = "invalid argument" + + +class InvalidSessionIdException(MarionetteException): + status = "invalid session id" + + +class TimeoutException(MarionetteException): + status = "timeout" + + +class JavascriptException(MarionetteException): + status = "javascript error" + + +class NoSuchElementException(MarionetteException): + status = "no such element" + + +class NoSuchShadowRootException(MarionetteException): + status = "no such shadow root" + + +class NoSuchWindowException(MarionetteException): + status = "no such window" + + +class StaleElementException(MarionetteException): + status = "stale element reference" + + +class ScriptTimeoutException(MarionetteException): + status = "script timeout" + + +class ElementNotVisibleException(MarionetteException): + """Deprecated. Will be removed with the release of Firefox 54.""" + + status = "element not visible" + + def __init__( + self, + message="Element is not currently visible and may not be manipulated", + stacktrace=None, + cause=None, + ): + super(ElementNotVisibleException, self).__init__( + message, cause=cause, stacktrace=stacktrace + ) + + +class ElementNotAccessibleException(MarionetteException): + status = "element not accessible" + + +class ElementNotInteractableException(MarionetteException): + status = "element not interactable" + + +class NoSuchFrameException(MarionetteException): + status = "no such frame" + + +class InvalidElementStateException(MarionetteException): + status = "invalid element state" + + +class NoAlertPresentException(MarionetteException): + status = "no such alert" + + +class InvalidCookieDomainException(MarionetteException): + status = "invalid cookie domain" + + +class UnableToSetCookieException(MarionetteException): + status = "unable to set cookie" + + +class InvalidElementCoordinates(MarionetteException): + status = "invalid element coordinates" + + +class InvalidSelectorException(MarionetteException): + status = "invalid selector" + + +class MoveTargetOutOfBoundsException(MarionetteException): + status = "move target out of bounds" + + +class SessionNotCreatedException(MarionetteException): + status = "session not created" + + +class UnexpectedAlertOpen(MarionetteException): + status = "unexpected alert open" + + +class UnknownCommandException(MarionetteException): + status = "unknown command" + + +class UnknownException(MarionetteException): + status = "unknown error" + + +class UnsupportedOperationException(MarionetteException): + status = "unsupported operation" + + +class UnresponsiveInstanceException(Exception): + pass + + +es_ = [ + e + for e in locals().values() + if type(e) == type and issubclass(e, MarionetteException) +] +by_string = {e.status: e for e in es_} + + +def lookup(identifier): + """Finds error exception class by associated Selenium JSON wire + protocol number code, or W3C WebDriver protocol string. + + """ + return by_string.get(identifier, MarionetteException) diff --git a/testing/marionette/client/marionette_driver/expected.py b/testing/marionette/client/marionette_driver/expected.py new file mode 100644 index 0000000000..37c415686c --- /dev/null +++ b/testing/marionette/client/marionette_driver/expected.py @@ -0,0 +1,315 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import types + +from . import errors +from .marionette import WebElement + +"""This file provides a set of expected conditions for common use +cases when writing Marionette tests. + +The conditions rely on explicit waits that retries conditions a number +of times until they are either successfully met, or they time out. + +""" + + +class element_present(object): + """Checks that a web element is present in the DOM of the current + context. This does not necessarily mean that the element is + visible. + + You can select which element to be checked for presence by + supplying a locator:: + + el = Wait(marionette).until(expected.element_present(By.ID, "foo")) + + Or by using a function/lambda returning an element:: + + el = Wait(marionette).until( + expected.element_present(lambda m: m.find_element(By.ID, "foo"))) + + :param args: locator or function returning web element + :returns: the web element once it is located, or False + + """ + + def __init__(self, *args): + if len(args) == 1 and isinstance(args[0], types.FunctionType): + self.locator = args[0] + else: + self.locator = lambda m: m.find_element(*args) + + def __call__(self, marionette): + return _find(marionette, self.locator) + + +class element_not_present(element_present): + """Checks that a web element is not present in the DOM of the current + context. + + You can select which element to be checked for lack of presence by + supplying a locator:: + + r = Wait(marionette).until(expected.element_not_present(By.ID, "foo")) + + Or by using a function/lambda returning an element:: + + r = Wait(marionette).until( + expected.element_present(lambda m: m.find_element(By.ID, "foo"))) + + :param args: locator or function returning web element + :returns: True if element is not present, or False if it is present + + """ + + def __init__(self, *args): + super(element_not_present, self).__init__(*args) + + def __call__(self, marionette): + return not super(element_not_present, self).__call__(marionette) + + +class element_stale(object): + """Check that the given element is no longer attached to DOM of the + current context. + + This can be useful for waiting until an element is no longer + present. + + Sample usage:: + + el = marionette.find_element(By.ID, "foo") + # ... + Wait(marionette).until(expected.element_stale(el)) + + :param element: the element to wait for + :returns: False if the element is still attached to the DOM, True + otherwise + + """ + + def __init__(self, element): + self.el = element + + def __call__(self, marionette): + try: + # Calling any method forces a staleness check + self.el.is_enabled() + return False + except (errors.StaleElementException, errors.NoSuchElementException): + # StaleElementException is raised when the element is gone, and + # NoSuchElementException is raised after a process swap. + return True + + +class elements_present(object): + """Checks that web elements are present in the DOM of the current + context. This does not necessarily mean that the elements are + visible. + + You can select which elements to be checked for presence by + supplying a locator:: + + els = Wait(marionette).until(expected.elements_present(By.TAG_NAME, "a")) + + Or by using a function/lambda returning a list of elements:: + + els = Wait(marionette).until( + expected.elements_present(lambda m: m.find_elements(By.TAG_NAME, "a"))) + + :param args: locator or function returning a list of web elements + :returns: list of web elements once they are located, or False + + """ + + def __init__(self, *args): + if len(args) == 1 and isinstance(args[0], types.FunctionType): + self.locator = args[0] + else: + self.locator = lambda m: m.find_elements(*args) + + def __call__(self, marionette): + return _find(marionette, self.locator) + + +class elements_not_present(elements_present): + """Checks that web elements are not present in the DOM of the + current context. + + You can select which elements to be checked for not being present + by supplying a locator:: + + r = Wait(marionette).until(expected.elements_not_present(By.TAG_NAME, "a")) + + Or by using a function/lambda returning a list of elements:: + + r = Wait(marionette).until( + expected.elements_not_present(lambda m: m.find_elements(By.TAG_NAME, "a"))) + + :param args: locator or function returning a list of web elements + :returns: True if elements are missing, False if one or more are + present + + """ + + def __init__(self, *args): + super(elements_not_present, self).__init__(*args) + + def __call__(self, marionette): + return not super(elements_not_present, self).__call__(marionette) + + +class element_displayed(object): + """An expectation for checking that an element is visible. + + Visibility means that the element is not only displayed, but also + has a height and width that is greater than 0 pixels. + + Stale elements, meaning elements that have been detached from the + DOM of the current context are treated as not being displayed, + meaning this expectation is not analogous to the behaviour of + calling :func:`~marionette_driver.marionette.WebElement.is_displayed` + on an :class:`~marionette_driver.marionette.WebElement`. + + 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], WebElement): + self.el = args[0] + else: + self.locator = lambda m: m.find_element(*args) + + def __call__(self, marionette): + if self.el is None: + self.el = _find(marionette, self.locator) + if not self.el: + return False + try: + return self.el.is_displayed() + except errors.StaleElementException: + return False + + +class element_not_displayed(element_displayed): + """An expectation for checking that an element is not visible. + + Visibility means that the element is not only displayed, but also + has a height and width that is greater than 0 pixels. + + Stale elements, meaning elements that have been detached fom the + DOM of the current context are treated as not being displayed, + meaning this expectation is not analogous to the behaviour of + calling :func:`~marionette_driver.marionette.WebElement.is_displayed` + on an :class:`~marionette_driver.marionette.WebElement`. + + You can select which element to be checked for visibility by + supplying a locator:: + + hidden = Wait(marionette).until(expected.element_not_displayed(By.ID, "foo")) + + Or by supplying an element:: + + el = marionette.find_element(By.ID, "foo") + hidden = Wait(marionette).until(expected.element_not_displayed(el)) + + :param args: locator or web element + :returns: True if element is hidden, False if displayed + + """ + + def __init__(self, *args): + super(element_not_displayed, self).__init__(*args) + + def __call__(self, marionette): + return not super(element_not_displayed, self).__call__(marionette) + + +class element_selected(object): + """An expectation for checking that the given element is selected. + + :param element: the element to be selected + :returns: True if element is selected, False otherwise + + """ + + def __init__(self, element): + self.el = element + + def __call__(self, marionette): + return self.el.is_selected() + + +class element_not_selected(element_selected): + """An expectation for checking that the given element is not + selected. + + :param element: the element to not be selected + :returns: True if element is not selected, False if selected + + """ + + def __init__(self, element): + super(element_not_selected, self).__init__(element) + + def __call__(self, marionette): + return not super(element_not_selected, self).__call__(marionette) + + +class element_enabled(object): + """An expectation for checking that the given element is enabled. + + :param element: the element to check if enabled + :returns: True if element is enabled, False otherwise + + """ + + def __init__(self, element): + self.el = element + + def __call__(self, marionette): + return self.el.is_enabled() + + +class element_not_enabled(element_enabled): + """An expectation for checking that the given element is disabled. + + :param element: the element to check if disabled + :returns: True if element is disabled, False if enabled + + """ + + def __init__(self, element): + super(element_not_enabled, self).__init__(element) + + def __call__(self, marionette): + return not super(element_not_enabled, self).__call__(marionette) + + +def _find(marionette, func): + el = None + + try: + el = func(marionette) + except errors.NoSuchElementException: + pass + + if el is None: + return False + return el diff --git a/testing/marionette/client/marionette_driver/geckoinstance.py b/testing/marionette/client/marionette_driver/geckoinstance.py new file mode 100644 index 0000000000..b0bec22ea0 --- /dev/null +++ b/testing/marionette/client/marionette_driver/geckoinstance.py @@ -0,0 +1,663 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/ + +# ALL CHANGES TO THIS FILE MUST HAVE REVIEW FROM A MARIONETTE PEER! +# +# Please refer to INSTRUCTIONS TO ADD A NEW PREFERENCE in +# remote/shared/RecommendedPreferences.sys.mjs +# +# The Marionette Python client is used out-of-tree with various builds of +# Firefox. Removing a preference from this file will cause regressions, +# so please be careful and get review from a Testing :: Marionette peer +# before you make any changes to this file. + +import codecs +import json +import os +import sys +import tempfile +import time +import traceback +from copy import deepcopy + +import mozversion +import six +from mozprofile import Profile +from mozrunner import FennecEmulatorRunner, Runner +from six import reraise + +from . import errors + + +class GeckoInstance(object): + required_prefs = { + # Make sure Shield doesn't hit the network. + "app.normandy.api_url": "", + # Increase the APZ content response timeout in tests to 1 minute. + # This is to accommodate the fact that test environments tends to be slower + # than production environments (with the b2g emulator being the slowest of them + # all), resulting in the production timeout value sometimes being exceeded + # and causing false-positive test failures. See bug 1176798, bug 1177018, + # bug 1210465. + "apz.content_response_timeout": 60000, + # Don't pull sponsored Top Sites content from the network + "browser.newtabpage.activity-stream.showSponsoredTopSites": False, + # Disable geolocation ping (#1) + "browser.region.network.url": "", + # Don't pull Top Sites content from the network + "browser.topsites.contile.enabled": False, + # Disable UI tour + "browser.uitour.enabled": False, + # Disable captive portal + "captivedetect.canonicalURL": "", + # Defensively disable data reporting systems + "datareporting.healthreport.documentServerURI": "http://%(server)s/dummy/healthreport/", + "datareporting.healthreport.logging.consoleEnabled": False, + "datareporting.healthreport.service.enabled": False, + "datareporting.healthreport.service.firstRun": False, + "datareporting.healthreport.uploadEnabled": False, + # Do not show datareporting policy notifications which can interfere with tests + "datareporting.policy.dataSubmissionEnabled": False, + "datareporting.policy.dataSubmissionPolicyBypassNotification": True, + # Enabling the support for File object creation in the content process. + "dom.file.createInChild": True, + # Disable delayed user input event handling + "dom.input_events.security.minNumTicks": 0, + # Disable delayed user input event handling + "dom.input_events.security.minTimeElapsedInMS": 0, + # Disable the ProcessHangMonitor + "dom.ipc.reportProcessHangs": False, + # No slow script dialogs + "dom.max_chrome_script_run_time": 0, + "dom.max_script_run_time": 0, + # Disable location change rate limitation + "dom.navigation.locationChangeRateLimit.count": 0, + # DOM Push + "dom.push.connection.enabled": False, + # Screen Orientation API + "dom.screenorientation.allow-lock": True, + # Disable dialog abuse if alerts are triggered too quickly + "dom.successive_dialog_time_limit": 0, + # Only load extensions from the application and user profile + # AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION + "extensions.autoDisableScopes": 0, + "extensions.enabledScopes": 5, + # Disable metadata caching for installed add-ons by default + "extensions.getAddons.cache.enabled": False, + # Disable intalling any distribution add-ons + "extensions.installDistroAddons": False, + # Turn off extension updates so they don't bother tests + "extensions.update.enabled": False, + "extensions.update.notifyUser": False, + # Redirect various extension update URLs + "extensions.blocklist.detailsURL": ( + "http://%(server)s/extensions-dummy/blocklistDetailsURL" + ), + "extensions.blocklist.itemURL": "http://%(server)s/extensions-dummy/blocklistItemURL", + "extensions.hotfix.url": "http://%(server)s/extensions-dummy/hotfixURL", + "extensions.systemAddon.update.url": "http://%(server)s/dummy-system-addons.xml", + "extensions.update.background.url": ( + "http://%(server)s/extensions-dummy/updateBackgroundURL" + ), + "extensions.update.url": "http://%(server)s/extensions-dummy/updateURL", + # Make sure opening about:addons won"t hit the network + "extensions.getAddons.discovery.api_url": "data:, ", + "extensions.getAddons.get.url": "http://%(server)s/extensions-dummy/repositoryGetURL", + "extensions.getAddons.search.browseURL": ( + "http://%(server)s/extensions-dummy/repositoryBrowseURL" + ), + # Allow the application to have focus even it runs in the background + "focusmanager.testmode": True, + # Disable useragent updates + "general.useragent.updates.enabled": False, + # Disable geolocation ping (#2) + "geo.provider.network.url": "", + # Always use network provider for geolocation tests + # so we bypass the OSX dialog raised by the corelocation provider + "geo.provider.testing": True, + # Do not scan Wifi + "geo.wifi.scan": False, + # Ensure webrender is on, no need for environment variables + "gfx.webrender.all": True, + # Disable idle-daily notifications to avoid expensive operations + # that may cause unexpected test timeouts. + "idle.lastDailyNotification": -1, + # Disable Firefox accounts ping + "identity.fxaccounts.auth.uri": "https://{server}/dummy/fxa", + # Disable download and usage of OpenH264, and Widevine plugins + "media.gmp-manager.updateEnabled": False, + # Disable the GFX sanity window + "media.sanity-test.disabled": True, + "media.volume_scale": "0.01", + # Disable connectivity service pings + "network.connectivity-service.enabled": False, + # Do not prompt for temporary redirects + "network.http.prompt-temp-redirect": False, + # Do not automatically switch between offline and online + "network.manage-offline-status": False, + # Make sure SNTP requests don't hit the network + "network.sntp.pools": "%(server)s", + # Privacy and Tracking Protection + "privacy.trackingprotection.enabled": False, + # Disable recommended automation prefs in CI + "remote.prefs.recommended": False, + # Don't do network connections for mitm priming + "security.certerrors.mitm.priming.enabled": False, + # Tests don't wait for the notification button security delay + "security.notification_enable_delay": 0, + # Do not download intermediate certificates + "security.remote_settings.intermediates.enabled": False, + # Ensure blocklist updates don't hit the network + "services.settings.server": "data:,#remote-settings-dummy/v1", + # Disable password capture, so that tests that include forms aren"t + # influenced by the presence of the persistent doorhanger notification + "signon.rememberSignons": False, + # Prevent starting into safe mode after application crashes + "toolkit.startup.max_resumed_crashes": -1, + # Disable most telemetry pings + "toolkit.telemetry.server": "https://%(server)s/telemetry-dummy/", + # Disable window occlusion on Windows, see Bug 1802473. + "widget.windows.window_occlusion_tracking.enabled": False, + } + + def __init__( + self, + host=None, + port=None, + bin=None, + profile=None, + addons=None, + app_args=None, + symbols_path=None, + gecko_log=None, + prefs=None, + workspace=None, + verbose=0, + headless=False, + ): + self.runner_class = Runner + self.app_args = app_args or [] + self.runner = None + self.symbols_path = symbols_path + self.binary = bin + + self.marionette_host = host + self.marionette_port = port + self.addons = addons + self.prefs = prefs + self.required_prefs = deepcopy(self.required_prefs) + if prefs: + self.required_prefs.update(prefs) + + self._gecko_log_option = gecko_log + self._gecko_log = None + self.verbose = verbose + self.headless = headless + + # keep track of errors to decide whether instance is unresponsive + self.unresponsive_count = 0 + + # Alternative to default temporary directory + self.workspace = workspace + + # Don't use the 'profile' property here, because sub-classes could add + # further preferences and data, which would not be included in the new + # profile + self._profile = profile + + @property + def gecko_log(self): + if self._gecko_log: + return self._gecko_log + + path = self._gecko_log_option + if path != "-": + if path is None: + path = "gecko.log" + elif os.path.isdir(path): + fname = "gecko-{}.log".format(time.time()) + path = os.path.join(path, fname) + + path = os.path.realpath(path) + if os.access(path, os.F_OK): + os.remove(path) + + self._gecko_log = path + return self._gecko_log + + @property + def profile(self): + return self._profile + + @profile.setter + def profile(self, value): + self._update_profile(value) + + def _update_profile(self, profile=None, profile_name=None): + """Check if the profile has to be created, or replaced. + + :param profile: A Profile instance to be used. + :param name: Profile name to be used in the path. + """ + if self.runner and self.runner.is_running(): + raise errors.MarionetteException( + "The current profile can only be updated " + "when the instance is not running" + ) + + if isinstance(profile, Profile): + # Only replace the profile if it is not the current one + if hasattr(self, "_profile") and profile is self._profile: + return + + else: + profile_args = self.profile_args + profile_path = profile + + # If a path to a profile is given then clone it + if isinstance(profile_path, six.string_types): + profile_args["path_from"] = profile_path + profile_args["path_to"] = tempfile.mkdtemp( + suffix=".{}".format(profile_name or os.path.basename(profile_path)), + dir=self.workspace, + ) + # The target must not exist yet + os.rmdir(profile_args["path_to"]) + + profile = Profile.clone(**profile_args) + + # Otherwise create a new profile + else: + profile_args["profile"] = tempfile.mkdtemp( + suffix=".{}".format(profile_name or "mozrunner"), + dir=self.workspace, + ) + profile = Profile(**profile_args) + profile.create_new = True + + if isinstance(self.profile, Profile): + self.profile.cleanup() + + self._profile = profile + + def switch_profile(self, profile_name=None, clone_from=None): + """Switch the profile by using the given name, and optionally clone it. + + Compared to :attr:`profile` this method allows to switch the profile + by giving control over the profile name as used for the new profile. It + also always creates a new blank profile, or as clone of an existent one. + + :param profile_name: Optional, name of the profile, which will be used + as part of the profile path (folder name containing the profile). + :clone_from: Optional, if specified the new profile will be cloned + based on the given profile. This argument can be an instance of + ``mozprofile.Profile``, or the path of the profile. + """ + if isinstance(clone_from, Profile): + clone_from = clone_from.profile + + self._update_profile(clone_from, profile_name=profile_name) + + @property + def profile_args(self): + args = {"preferences": deepcopy(self.required_prefs)} + args["preferences"]["marionette.port"] = self.marionette_port + args["preferences"]["marionette.defaultPrefs.port"] = self.marionette_port + + if self.prefs: + args["preferences"].update(self.prefs) + + if self.verbose: + level = "Trace" if self.verbose >= 2 else "Debug" + args["preferences"]["remote.log.level"] = level + + if "-jsdebugger" in self.app_args: + args["preferences"].update( + { + "devtools.browsertoolbox.panel": "jsdebugger", + "devtools.chrome.enabled": True, + "devtools.debugger.prompt-connection": False, + "devtools.debugger.remote-enabled": True, + "devtools.testing": True, + } + ) + + if self.addons: + args["addons"] = self.addons + + return args + + @classmethod + def create(cls, app=None, *args, **kwargs): + try: + if not app and kwargs["bin"] is not None: + app_id = mozversion.get_version(binary=kwargs["bin"])["application_id"] + app = app_ids[app_id] + + instance_class = apps[app] + except (IOError, KeyError): + exc, val, tb = sys.exc_info() + msg = 'Application "{0}" unknown (should be one of {1})'.format( + app, list(apps.keys()) + ) + reraise(NotImplementedError, NotImplementedError(msg), tb) + + return instance_class(*args, **kwargs) + + def start(self): + self._update_profile(self.profile) + self.runner = self.runner_class(**self._get_runner_args()) + self.runner.start() + + def _get_runner_args(self): + process_args = { + "processOutputLine": [NullOutput()], + "universal_newlines": True, + } + + if self.gecko_log == "-": + if hasattr(sys.stdout, "buffer"): + process_args["stream"] = codecs.getwriter("utf-8")(sys.stdout.buffer) + else: + process_args["stream"] = codecs.getwriter("utf-8")(sys.stdout) + else: + process_args["logfile"] = self.gecko_log + + env = os.environ.copy() + + # Store all required preferences for tests which need to create clean profiles. + required_prefs_keys = list(self.required_prefs.keys()) + env["MOZ_MARIONETTE_REQUIRED_PREFS"] = json.dumps(required_prefs_keys) + + if self.headless: + env["MOZ_HEADLESS"] = "1" + env["DISPLAY"] = "77" # Set a fake display. + + # environment variables needed for crashreporting + # https://developer.mozilla.org/docs/Environment_variables_affecting_crash_reporting + env.update( + { + "MOZ_CRASHREPORTER": "1", + "MOZ_CRASHREPORTER_NO_REPORT": "1", + "MOZ_CRASHREPORTER_SHUTDOWN": "1", + } + ) + + return { + "binary": self.binary, + "profile": self.profile, + "cmdargs": ["-no-remote", "-marionette"] + self.app_args, + "env": env, + "symbols_path": self.symbols_path, + "process_args": process_args, + } + + def close(self, clean=False): + """ + Close the managed Gecko process. + + Depending on self.runner_class, setting `clean` to True may also kill + the emulator process in which this instance is running. + + :param clean: If True, also perform runner cleanup. + """ + if self.runner: + self.runner.stop() + if clean: + self.runner.cleanup() + + if clean: + if isinstance(self.profile, Profile): + self.profile.cleanup() + self.profile = None + + def restart(self, prefs=None, clean=True): + """ + Close then start the managed Gecko process. + + :param prefs: Dictionary of preference names and values. + :param clean: If True, reset the profile before starting. + """ + if prefs: + self.prefs = prefs + else: + self.prefs = None + + self.close(clean=clean) + self.start() + + +class FennecInstance(GeckoInstance): + fennec_prefs = { + # Enable output for dump() and chrome console API + "browser.dom.window.dump.enabled": True, + "devtools.console.stdout.chrome": True, + # Disable safe browsing / tracking protection updates + "browser.safebrowsing.update.enabled": False, + # Do not restore the last open set of tabs if the browser has crashed + "browser.sessionstore.resume_from_crash": False, + } + + def __init__( + self, + emulator_binary=None, + avd_home=None, + avd=None, + adb_path=None, + serial=None, + connect_to_running_emulator=False, + package_name=None, + env=None, + *args, + **kwargs + ): + required_prefs = deepcopy(FennecInstance.fennec_prefs) + required_prefs.update(kwargs.get("prefs", {})) + + super(FennecInstance, self).__init__(*args, **kwargs) + self.required_prefs.update(required_prefs) + + self.runner_class = FennecEmulatorRunner + # runner args + self._package_name = package_name + self.emulator_binary = emulator_binary + self.avd_home = avd_home + self.adb_path = adb_path + self.avd = avd + self.env = env + self.serial = serial + self.connect_to_running_emulator = connect_to_running_emulator + + @property + def package_name(self): + """ + Name of app to run on emulator. + + Note that FennecInstance does not use self.binary + """ + if self._package_name is None: + self._package_name = "org.mozilla.fennec" + user = os.getenv("USER") + if user: + self._package_name += "_" + user + return self._package_name + + def start(self): + self._update_profile(self.profile) + self.runner = self.runner_class(**self._get_runner_args()) + try: + if self.connect_to_running_emulator: + self.runner.device.connect() + self.runner.start() + except Exception: + exc_cls, exc, tb = sys.exc_info() + reraise( + exc_cls, + exc_cls("Error possibly due to runner or device args: {}".format(exc)), + tb, + ) + + # forward marionette port + self.runner.device.device.forward( + local="tcp:{}".format(self.marionette_port), + remote="tcp:{}".format(self.marionette_port), + ) + + def _get_runner_args(self): + process_args = { + "processOutputLine": [NullOutput()], + "universal_newlines": True, + } + + env = {} if self.env is None else self.env.copy() + + runner_args = { + "app": self.package_name, + "avd_home": self.avd_home, + "adb_path": self.adb_path, + "binary": self.emulator_binary, + "env": env, + "profile": self.profile, + "cmdargs": ["-marionette"] + self.app_args, + "symbols_path": self.symbols_path, + "process_args": process_args, + "logdir": self.workspace or os.getcwd(), + "serial": self.serial, + } + if self.avd: + runner_args["avd"] = self.avd + + return runner_args + + def close(self, clean=False): + """ + Close the managed Gecko process. + + If `clean` is True and the Fennec instance is running in an + emulator managed by mozrunner, this will stop the emulator. + + :param clean: If True, also perform runner cleanup. + """ + super(FennecInstance, self).close(clean) + if clean and self.runner and self.runner.device.connected: + try: + self.runner.device.device.remove_forwards( + "tcp:{}".format(self.marionette_port) + ) + self.unresponsive_count = 0 + except Exception: + self.unresponsive_count += 1 + traceback.print_exception(*sys.exc_info()) + + +class DesktopInstance(GeckoInstance): + desktop_prefs = { + # Disable Firefox old build background check + "app.update.checkInstallTime": False, + # Disable automatically upgrading Firefox + # + # Note: Possible update tests could reset or flip the value to allow + # updates to be downloaded and applied. + "app.update.disabledForTesting": True, + # !!! For backward compatibility up to Firefox 64. Only remove + # when this Firefox version is no longer supported by the client !!! + "app.update.auto": False, + # Don't show the content blocking introduction panel + # We use a larger number than the default 22 to have some buffer + # This can be removed once Firefox 69 and 68 ESR and are no longer supported. + "browser.contentblocking.introCount": 99, + # Enable output for dump() and chrome console API + "browser.dom.window.dump.enabled": True, + "devtools.console.stdout.chrome": True, + # Indicate that the download panel has been shown once so that whichever + # download test runs first doesn"t show the popup inconsistently + "browser.download.panel.shown": True, + # Do not show the EULA notification which can interfer with tests + "browser.EULA.override": True, + # Disable Activity Stream telemetry pings + "browser.newtabpage.activity-stream.telemetry": False, + # Always display a blank page + "browser.newtabpage.enabled": False, + # Background thumbnails in particular cause grief, and disabling thumbnails + # in general can"t hurt - we re-enable them when tests need them + "browser.pagethumbnails.capturing_disabled": True, + # Disable safe browsing / tracking protection updates + "browser.safebrowsing.update.enabled": False, + # Disable updates to search engines + "browser.search.update": False, + # Do not restore the last open set of tabs if the browser has crashed + "browser.sessionstore.resume_from_crash": False, + # Don't check for the default web browser during startup + "browser.shell.checkDefaultBrowser": False, + # Disable session restore infobar + "browser.startup.couldRestoreSession.count": -1, + # Needed for branded builds to prevent opening a second tab on startup + "browser.startup.homepage_override.mstone": "ignore", + # Start with a blank page by default + "browser.startup.page": 0, + # Don't unload tabs when available memory is running low + "browser.tabs.unloadOnLowMemory": False, + # Do not warn when closing all open tabs + "browser.tabs.warnOnClose": False, + # Do not warn when closing all other open tabs + "browser.tabs.warnOnCloseOtherTabs": False, + # Do not warn when multiple tabs will be opened + "browser.tabs.warnOnOpen": False, + # Don't show the Bookmarks Toolbar on any tab (the above pref that + # disables the New Tab Page ends up showing the toolbar on about:blank). + "browser.toolbars.bookmarks.visibility": "never", + # Disable the UI tour + "browser.uitour.enabled": False, + # Turn off Merino suggestions in the location bar so as not to trigger network + # connections. + "browser.urlbar.merino.endpointURL": "", + # Turn off search suggestions in the location bar so as not to trigger network + # connections. + "browser.urlbar.suggest.searches": False, + # Don't warn when exiting the browser + "browser.warnOnQuit": False, + # Disable first-run welcome page + "startup.homepage_welcome_url": "about:blank", + "startup.homepage_welcome_url.additional": "", + } + + def __init__(self, *args, **kwargs): + required_prefs = deepcopy(DesktopInstance.desktop_prefs) + required_prefs.update(kwargs.get("prefs", {})) + + super(DesktopInstance, self).__init__(*args, **kwargs) + self.required_prefs.update(required_prefs) + + +class ThunderbirdInstance(GeckoInstance): + def __init__(self, *args, **kwargs): + super(ThunderbirdInstance, self).__init__(*args, **kwargs) + try: + # Copied alongside in the test archive + from .thunderbirdinstance import thunderbird_prefs + except ImportError: + try: + # Coming from source tree through virtualenv + from thunderbirdinstance import thunderbird_prefs + except ImportError: + thunderbird_prefs = {} + self.required_prefs.update(thunderbird_prefs) + + +class NullOutput(object): + def __call__(self, line): + pass + + +apps = { + "fennec": FennecInstance, + "fxdesktop": DesktopInstance, + "thunderbird": ThunderbirdInstance, +} + +app_ids = { + "{aa3c5121-dab2-40e2-81ca-7ea25febc110}": "fennec", + "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}": "fxdesktop", + "{3550f703-e582-4d05-9a08-453d09bdfdc6}": "thunderbird", +} diff --git a/testing/marionette/client/marionette_driver/keys.py b/testing/marionette/client/marionette_driver/keys.py new file mode 100644 index 0000000000..18b547caa7 --- /dev/null +++ b/testing/marionette/client/marionette_driver/keys.py @@ -0,0 +1,87 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# copyright 2008-2009 WebDriver committers +# Copyright 2008-2009 Google Inc. +# +# Licensed under the Apache License Version 2.0 = uthe "License") +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http //www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing software +# distributed under the License is distributed on an "AS IS" BASIS +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class Keys(object): + NULL = "\ue000" + CANCEL = "\ue001" # ^break + HELP = "\ue002" + BACK_SPACE = "\ue003" + TAB = "\ue004" + CLEAR = "\ue005" + RETURN = "\ue006" + ENTER = "\ue007" + SHIFT = "\ue008" + LEFT_SHIFT = "\ue008" # alias + CONTROL = "\ue009" + LEFT_CONTROL = "\ue009" # alias + ALT = "\ue00a" + LEFT_ALT = "\ue00a" # alias + PAUSE = "\ue00b" + ESCAPE = "\ue00c" + SPACE = "\ue00d" + PAGE_UP = "\ue00e" + PAGE_DOWN = "\ue00f" + END = "\ue010" + HOME = "\ue011" + LEFT = "\ue012" + ARROW_LEFT = "\ue012" # alias + UP = "\ue013" + ARROW_UP = "\ue013" # alias + RIGHT = "\ue014" + ARROW_RIGHT = "\ue014" # alias + DOWN = "\ue015" + ARROW_DOWN = "\ue015" # alias + INSERT = "\ue016" + DELETE = "\ue017" + SEMICOLON = "\ue018" + EQUALS = "\ue019" + + NUMPAD0 = "\ue01a" # numbe pad keys + NUMPAD1 = "\ue01b" + NUMPAD2 = "\ue01c" + NUMPAD3 = "\ue01d" + NUMPAD4 = "\ue01e" + NUMPAD5 = "\ue01f" + NUMPAD6 = "\ue020" + NUMPAD7 = "\ue021" + NUMPAD8 = "\ue022" + NUMPAD9 = "\ue023" + MULTIPLY = "\ue024" + ADD = "\ue025" + SEPARATOR = "\ue026" + SUBTRACT = "\ue027" + DECIMAL = "\ue028" + DIVIDE = "\ue029" + + F1 = "\ue031" # function keys + F2 = "\ue032" + F3 = "\ue033" + F4 = "\ue034" + F5 = "\ue035" + F6 = "\ue036" + F7 = "\ue037" + F8 = "\ue038" + F9 = "\ue039" + F10 = "\ue03a" + F11 = "\ue03b" + F12 = "\ue03c" + + META = "\ue03d" + COMMAND = "\ue03d" diff --git a/testing/marionette/client/marionette_driver/localization.py b/testing/marionette/client/marionette_driver/localization.py new file mode 100644 index 0000000000..fccb32f416 --- /dev/null +++ b/testing/marionette/client/marionette_driver/localization.py @@ -0,0 +1,54 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + + +class L10n(object): + """An API which allows Marionette to handle localized content. + + The `localization`_ of UI elements in Gecko based applications is done via + entities and properties. For static values entities are used, which are located + in .dtd files. Whereby for dynamically updated content the values come from + .property files. Both types of elements can be identifed via a unique id, + and the translated content retrieved. + + For example:: + + from marionette_driver.localization import L10n + l10n = L10n(marionette) + + l10n.localize_entity(["chrome://branding/locale/brand.dtd"], "brandShortName") + l10n.localize_property(["chrome://global/locale/findbar.properties"], "FastFind")) + + .. _localization: https://mzl.la/2eUMjyF + """ + + def __init__(self, marionette): + self._marionette = marionette + + def localize_entity(self, dtd_urls, entity_id): + """Retrieve the localized string for the specified entity id. + + :param dtd_urls: List of .dtd URLs which will be used to search for the entity. + :param entity_id: ID of the entity to retrieve the localized string for. + + :returns: The localized string for the requested entity. + :raises: :exc:`NoSuchElementException` + """ + body = {"urls": dtd_urls, "id": entity_id} + return self._marionette._send_message("L10n:LocalizeEntity", body, key="value") + + def localize_property(self, properties_urls, property_id): + """Retrieve the localized string for the specified property id. + + :param properties_urls: List of .properties URLs which will be used to + search for the property. + :param property_id: ID of the property to retrieve the localized string for. + + :returns: The localized string for the requested property. + :raises: :exc:`NoSuchElementException` + """ + body = {"urls": properties_urls, "id": property_id} + return self._marionette._send_message( + "L10n:LocalizeProperty", body, key="value" + ) diff --git a/testing/marionette/client/marionette_driver/marionette.py b/testing/marionette/client/marionette_driver/marionette.py new file mode 100644 index 0000000000..3fbc1b63d7 --- /dev/null +++ b/testing/marionette/client/marionette_driver/marionette.py @@ -0,0 +1,2183 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import base64 +import datetime +import json +import os +import socket +import sys +import time +import traceback +from contextlib import contextmanager + +import six +from six import reraise + +from . import errors, transport +from .decorators import do_process_check +from .geckoinstance import GeckoInstance +from .keys import Keys +from .timeout import Timeouts + +WEB_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf" +WEB_FRAME_KEY = "frame-075b-4da1-b6ba-e579c2d3230a" +WEB_SHADOW_ROOT_KEY = "shadow-6066-11e4-a52e-4f735466cecf" +WEB_WINDOW_KEY = "window-fcc6-11e5-b4f8-330a88ab9d7f" + + +class MouseButton(object): + """Enum-like class for mouse button constants.""" + + LEFT = 0 + MIDDLE = 1 + RIGHT = 2 + + +class ActionSequence(object): + r"""API for creating and performing action sequences. + + Each action method adds one or more actions to a queue. When perform() + is called, the queued actions fire in order. + + May be chained together as in:: + + ActionSequence(self.marionette, "key", id) \ + .key_down("a") \ + .key_up("a") \ + .perform() + """ + + def __init__(self, marionette, action_type, input_id, pointer_params=None): + self.marionette = marionette + self._actions = [] + self._id = input_id + self._pointer_params = pointer_params + self._type = action_type + + @property + def dict(self): + d = { + "type": self._type, + "id": self._id, + "actions": self._actions, + } + if self._pointer_params is not None: + d["parameters"] = self._pointer_params + return d + + def perform(self): + """Perform all queued actions.""" + self.marionette.actions.perform([self.dict]) + + def _key_action(self, subtype, value): + self._actions.append({"type": subtype, "value": value}) + + def _pointer_action(self, subtype, button): + self._actions.append({"type": subtype, "button": button}) + + def pause(self, duration): + self._actions.append({"type": "pause", "duration": duration}) + return self + + def pointer_move(self, x, y, duration=None, origin=None): + """Queue a pointerMove action. + + :param x: Destination x-axis coordinate of pointer in CSS pixels. + :param y: Destination y-axis coordinate of pointer in CSS pixels. + :param duration: Number of milliseconds over which to distribute the + move. If None, remote end defaults to 0. + :param origin: Origin of coordinates, either "viewport", "pointer" or + an Element. If None, remote end defaults to "viewport". + """ + action = {"type": "pointerMove", "x": x, "y": y} + if duration is not None: + action["duration"] = duration + if origin is not None: + if isinstance(origin, WebElement): + action["origin"] = {origin.kind: origin.id} + else: + action["origin"] = origin + self._actions.append(action) + return self + + def pointer_up(self, button=MouseButton.LEFT): + """Queue a pointerUp action for `button`. + + :param button: Pointer button to perform action with. + Default: 0, which represents main device button. + """ + self._pointer_action("pointerUp", button) + return self + + def pointer_down(self, button=MouseButton.LEFT): + """Queue a pointerDown action for `button`. + + :param button: Pointer button to perform action with. + Default: 0, which represents main device button. + """ + self._pointer_action("pointerDown", button) + return self + + def click(self, element=None, button=MouseButton.LEFT): + """Queue a click with the specified button. + + If an element is given, move the pointer to that element first, + otherwise click current pointer coordinates. + + :param element: Optional element to click. + :param button: Integer representing pointer button to perform action + with. Default: 0, which represents main device button. + """ + if element: + self.pointer_move(0, 0, origin=element) + return self.pointer_down(button).pointer_up(button) + + def key_down(self, value): + """Queue a keyDown action for `value`. + + :param value: Single character to perform key action with. + """ + self._key_action("keyDown", value) + return self + + def key_up(self, value): + """Queue a keyUp action for `value`. + + :param value: Single character to perform key action with. + """ + self._key_action("keyUp", value) + return self + + def scroll(self, x, y, delta_x, delta_y, duration=None, origin=None): + """Queue a scroll action. + + :param x: Destination x-axis coordinate of pointer in CSS pixels. + :param y: Destination y-axis coordinate of pointer in CSS pixels. + :param delta_x: Scroll delta for x-axis in CSS pixels. + :param delta_y: Scroll delta for y-axis in CSS pixels. + :param duration: Number of milliseconds over which to distribute the + scroll. If None, remote end defaults to 0. + :param origin: Origin of coordinates, either "viewport", "pointer" or + an Element. If None, remote end defaults to "viewport". + """ + action = { + "type": "scroll", + "x": x, + "y": y, + "deltaX": delta_x, + "deltaY": delta_y, + } + + if duration is not None: + action["duration"] = duration + if origin is not None: + if isinstance(origin, WebElement): + action["origin"] = {origin.kind: origin.id} + else: + action["origin"] = origin + self._actions.append(action) + return self + + def send_keys(self, keys): + """Queue a keyDown and keyUp action for each character in `keys`. + + :param keys: String of keys to perform key actions with. + """ + for c in keys: + self.key_down(c) + self.key_up(c) + return self + + +class Actions(object): + def __init__(self, marionette): + self.marionette = marionette + + def perform(self, actions=None): + """Perform actions by tick from each action sequence in `actions`. + + :param actions: List of input source action sequences. A single action + sequence may be created with the help of + ``ActionSequence.dict``. + """ + body = {"actions": [] if actions is None else actions} + return self.marionette._send_message("WebDriver:PerformActions", body) + + def release(self): + return self.marionette._send_message("WebDriver:ReleaseActions") + + def sequence(self, *args, **kwargs): + """Return an empty ActionSequence of the designated type. + + See ActionSequence for parameter list. + """ + return ActionSequence(self.marionette, *args, **kwargs) + + +class WebElement(object): + """Represents a DOM Element.""" + + identifiers = (WEB_ELEMENT_KEY,) + + def __init__(self, marionette, id, kind=WEB_ELEMENT_KEY): + self.marionette = marionette + assert id is not None + self.id = id + self.kind = kind + + def __str__(self): + return self.id + + def __eq__(self, other_element): + return self.id == other_element.id + + def __hash__(self): + # pylint --py3k: W1641 + return hash(self.id) + + def find_element(self, method, target): + """Returns an ``WebElement`` instance that matches the specified + method and target, relative to the current element. + + For more details on this function, see the + :func:`~marionette_driver.marionette.Marionette.find_element` method + in the Marionette class. + """ + return self.marionette.find_element(method, target, self.id) + + def find_elements(self, method, target): + """Returns a list of all ``WebElement`` instances that match the + specified method and target in the current context. + + For more details on this function, see the + :func:`~marionette_driver.marionette.Marionette.find_elements` method + in the Marionette class. + """ + return self.marionette.find_elements(method, target, self.id) + + def get_attribute(self, name): + """Returns the requested attribute, or None if no attribute + is set. + """ + body = {"id": self.id, "name": name} + return self.marionette._send_message( + "WebDriver:GetElementAttribute", body, key="value" + ) + + def get_property(self, name): + """Returns the requested property, or None if the property is + not set. + """ + try: + body = {"id": self.id, "name": name} + return self.marionette._send_message( + "WebDriver:GetElementProperty", body, key="value" + ) + except errors.UnknownCommandException: + # Keep backward compatibility for code which uses get_attribute() to + # also retrieve element properties. + # Remove when Firefox 55 is stable. + return self.get_attribute(name) + + def click(self): + """Simulates a click on the element.""" + self.marionette._send_message("WebDriver:ElementClick", {"id": self.id}) + + @property + def text(self): + """Returns the visible text of the element, and its child elements.""" + body = {"id": self.id} + return self.marionette._send_message( + "WebDriver:GetElementText", body, key="value" + ) + + def send_keys(self, *strings): + """Sends the string via synthesized keypresses to the element. + If an array is passed in like `marionette.send_keys(Keys.SHIFT, "a")` it + will be joined into a string. + If an integer is passed in like `marionette.send_keys(1234)` it will be + coerced into a string. + """ + keys = Marionette.convert_keys(*strings) + self.marionette._send_message( + "WebDriver:ElementSendKeys", {"id": self.id, "text": keys} + ) + + def clear(self): + """Clears the input of the element.""" + self.marionette._send_message("WebDriver:ElementClear", {"id": self.id}) + + def is_selected(self): + """Returns True if the element is selected.""" + body = {"id": self.id} + return self.marionette._send_message( + "WebDriver:IsElementSelected", body, key="value" + ) + + def is_enabled(self): + """This command will return False if all the following criteria + are met otherwise return True: + + * A form control is disabled. + * A ``WebElement`` has a disabled boolean attribute. + """ + body = {"id": self.id} + return self.marionette._send_message( + "WebDriver:IsElementEnabled", body, key="value" + ) + + def is_displayed(self): + """Returns True if the element is displayed, False otherwise.""" + body = {"id": self.id} + return self.marionette._send_message( + "WebDriver:IsElementDisplayed", body, key="value" + ) + + @property + def tag_name(self): + """The tag name of the element.""" + body = {"id": self.id} + return self.marionette._send_message( + "WebDriver:GetElementTagName", body, key="value" + ) + + @property + def rect(self): + """Gets the element's bounding rectangle. + + This will return a dictionary with the following: + + * x and y represent the top left coordinates of the ``WebElement`` + relative to top left corner of the document. + * height and the width will contain the height and the width + of the DOMRect of the ``WebElement``. + """ + return self.marionette._send_message( + "WebDriver:GetElementRect", {"id": self.id} + ) + + def value_of_css_property(self, property_name): + """Gets the value of the specified CSS property name. + + :param property_name: Property name to get the value of. + """ + body = {"id": self.id, "propertyName": property_name} + return self.marionette._send_message( + "WebDriver:GetElementCSSValue", body, key="value" + ) + + @property + def shadow_root(self): + """Gets the shadow root of the current element""" + return self.marionette._send_message( + "WebDriver:GetShadowRoot", {"id": self.id}, key="value" + ) + + @property + def computed_label(self): + """Gets the computed accessibility label of the current element""" + return self.marionette._send_message( + "WebDriver:GetComputedLabel", {"id": self.id}, key="value" + ) + + @property + def computed_role(self): + """Gets the computed accessibility role of the current element""" + return self.marionette._send_message( + "WebDriver:GetComputedRole", {"id": self.id}, key="value" + ) + + @classmethod + def _from_json(cls, json, marionette): + if isinstance(json, dict): + if WEB_ELEMENT_KEY in json: + return cls(marionette, json[WEB_ELEMENT_KEY], WEB_ELEMENT_KEY) + raise ValueError("Unrecognised web element") + + +class ShadowRoot(object): + """A Class to handling Shadow Roots""" + + identifiers = (WEB_SHADOW_ROOT_KEY,) + + def __init__(self, marionette, id, kind=WEB_SHADOW_ROOT_KEY): + self.marionette = marionette + assert id is not None + self.id = id + self.kind = kind + + def __str__(self): + return self.id + + def __eq__(self, other_element): + return self.id == other_element.id + + def __hash__(self): + # pylint --py3k: W1641 + return hash(self.id) + + def find_element(self, method, target): + """Returns a ``WebElement`` instance that matches the specified + method and target, relative to the current shadow root. + + For more details on this function, see the + :func:`~marionette_driver.marionette.Marionette.find_element` method + in the Marionette class. + """ + body = {"shadowRoot": self.id, "value": target, "using": method} + return self.marionette._send_message( + "WebDriver:FindElementFromShadowRoot", body, key="value" + ) + + def find_elements(self, method, target): + """Returns a list of all ``WebElement`` instances that match the + specified method and target in the current shadow root. + + For more details on this function, see the + :func:`~marionette_driver.marionette.Marionette.find_elements` method + in the Marionette class. + """ + body = {"shadowRoot": self.id, "value": target, "using": method} + return self.marionette._send_message( + "WebDriver:FindElementsFromShadowRoot", body + ) + + @classmethod + def _from_json(cls, json, marionette): + if isinstance(json, dict): + if WEB_SHADOW_ROOT_KEY in json: + return cls(marionette, json[WEB_SHADOW_ROOT_KEY]) + raise ValueError("Unrecognised shadow root") + + +class WebFrame(object): + """A Class to handle frame windows""" + + identifiers = (WEB_FRAME_KEY,) + + def __init__(self, marionette, id, kind=WEB_FRAME_KEY): + self.marionette = marionette + assert id is not None + self.id = id + self.kind = kind + + def __str__(self): + return self.id + + def __eq__(self, other_element): + return self.id == other_element.id + + def __hash__(self): + # pylint --py3k: W1641 + return hash(self.id) + + @classmethod + def _from_json(cls, json, marionette): + if isinstance(json, dict): + if WEB_FRAME_KEY in json: + return cls(marionette, json[WEB_FRAME_KEY]) + raise ValueError("Unrecognised web frame") + + +class WebWindow(object): + """A Class to handle top-level windows""" + + identifiers = (WEB_WINDOW_KEY,) + + def __init__(self, marionette, id, kind=WEB_WINDOW_KEY): + self.marionette = marionette + assert id is not None + self.id = id + self.kind = kind + + def __str__(self): + return self.id + + def __eq__(self, other_element): + return self.id == other_element.id + + def __hash__(self): + # pylint --py3k: W1641 + return hash(self.id) + + @classmethod + def _from_json(cls, json, marionette): + if isinstance(json, dict): + if WEB_WINDOW_KEY in json: + return cls(marionette, json[WEB_WINDOW_KEY]) + raise ValueError("Unrecognised web window") + + +class Alert(object): + """A class for interacting with alerts. + + :: + + Alert(marionette).accept() + Alert(marionette).dismiss() + """ + + def __init__(self, marionette): + self.marionette = marionette + + def accept(self): + """Accept a currently displayed modal dialog.""" + self.marionette._send_message("WebDriver:AcceptAlert") + + def dismiss(self): + """Dismiss a currently displayed modal dialog.""" + self.marionette._send_message("WebDriver:DismissAlert") + + @property + def text(self): + """Return the currently displayed text in a tab modal.""" + return self.marionette._send_message("WebDriver:GetAlertText", key="value") + + def send_keys(self, *string): + """Send keys to the currently displayed text input area in an open + tab modal dialog.""" + self.marionette._send_message( + "WebDriver:SendAlertText", {"text": Marionette.convert_keys(*string)} + ) + + +class Marionette(object): + """Represents a Marionette connection to a browser or device.""" + + CONTEXT_CHROME = "chrome" # non-browser content: windows, dialogs, etc. + CONTEXT_CONTENT = "content" # browser content: iframes, divs, etc. + DEFAULT_STARTUP_TIMEOUT = 120 + DEFAULT_SHUTDOWN_TIMEOUT = ( + 70 # By default Firefox will kill hanging threads after 60s + ) + + # Bug 1336953 - Until we can remove the socket timeout parameter it has to be + # set a default value which is larger than the longest timeout as defined by the + # WebDriver spec. In that case its 300s for page load. Also add another minute + # so that slow builds have enough time to send the timeout error to the client. + DEFAULT_SOCKET_TIMEOUT = 360 + + def __init__( + self, + host="127.0.0.1", + port=2828, + app=None, + bin=None, + baseurl=None, + socket_timeout=None, + startup_timeout=None, + **instance_args + ): + """Construct a holder for the Marionette connection. + + Remember to call ``start_session`` in order to initiate the + connection and start a Marionette session. + + :param host: Host where the Marionette server listens. + Defaults to 127.0.0.1. + :param port: Port where the Marionette server listens. + Defaults to port 2828. + :param baseurl: Where to look for files served from Marionette's + www directory. + :param socket_timeout: Timeout for Marionette socket operations. + :param startup_timeout: Seconds to wait for a connection with + binary. + :param bin: Path to browser binary. If any truthy value is given + this will attempt to start a Gecko instance with the specified + `app`. + :param app: Type of ``instance_class`` to use for managing app + instance. See ``marionette_driver.geckoinstance``. + :param instance_args: Arguments to pass to ``instance_class``. + """ + self.host = "127.0.0.1" # host + if int(port) == 0: + port = Marionette.check_port_available(port) + self.port = self.local_port = int(port) + self.bin = bin + self.client = None + self.instance = None + self.requested_capabilities = None + self.session = None + self.session_id = None + self.process_id = None + self.profile = None + self.window = None + self.chrome_window = None + self.baseurl = baseurl + self._test_name = None + self.crashed = 0 + self.is_shutting_down = False + self.cleanup_ran = False + + if socket_timeout is None: + self.socket_timeout = self.DEFAULT_SOCKET_TIMEOUT + else: + self.socket_timeout = float(socket_timeout) + + if startup_timeout is None: + self.startup_timeout = self.DEFAULT_STARTUP_TIMEOUT + else: + self.startup_timeout = int(startup_timeout) + + self.shutdown_timeout = self.DEFAULT_SHUTDOWN_TIMEOUT + + if self.bin: + self.instance = GeckoInstance.create( + app, host=self.host, port=self.port, bin=self.bin, **instance_args + ) + self.start_binary(self.startup_timeout) + + self.actions = Actions(self) + self.timeout = Timeouts(self) + + @property + def profile_path(self): + if self.instance and self.instance.profile: + return self.instance.profile.profile + + def start_binary(self, timeout): + try: + self.check_port_available(self.port, host=self.host) + except socket.error: + _, value, tb = sys.exc_info() + msg = "Port {}:{} is unavailable ({})".format(self.host, self.port, value) + reraise(IOError, IOError(msg), tb) + + try: + self.instance.start() + self.raise_for_port(timeout=timeout) + except socket.timeout: + # Something went wrong with starting up Marionette server. Given + # that the process will not quit itself, force a shutdown immediately. + self.cleanup() + + msg = ( + "Process killed after {}s because no connection to Marionette " + "server could be established. Check gecko.log for errors" + ) + reraise(IOError, IOError(msg.format(timeout)), sys.exc_info()[2]) + + def cleanup(self): + if self.session is not None: + try: + self.delete_session() + except (errors.MarionetteException, IOError): + # These exceptions get thrown if the Marionette server + # hit an exception/died or the connection died. We can + # do no further server-side cleanup in this case. + pass + + if self.instance: + # stop application and, if applicable, stop emulator + self.instance.close(clean=True) + if self.instance.unresponsive_count >= 3: + raise errors.UnresponsiveInstanceException( + "Application clean-up has failed >2 consecutive times." + ) + + self.cleanup_ran = True + + def __del__(self): + if not self.cleanup_ran: + self.cleanup() + + @staticmethod + def check_port_available(port, host=""): + """Check if "host:port" is available. + + Raise socket.error if port is not available. + """ + port = int(port) + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + s.bind((host, port)) + port = s.getsockname()[1] + finally: + s.close() + return port + + def raise_for_port(self, timeout=None, check_process_status=True): + """Raise socket.timeout if no connection can be established. + + :param timeout: Optional timeout in seconds for the server to be ready. + :param check_process_status: Optional, if `True` the process will be + continuously checked if it has exited, and the connection + attempt will be aborted. + """ + if timeout is None: + timeout = self.startup_timeout + + runner = None + if self.instance is not None: + runner = self.instance.runner + + poll_interval = 0.1 + starttime = datetime.datetime.now() + timeout_time = starttime + datetime.timedelta(seconds=timeout) + + client = transport.TcpTransport(self.host, self.port, 0.5) + + connected = False + while datetime.datetime.now() < timeout_time: + # If the instance we want to connect to is not running return immediately + if check_process_status and runner is not None and not runner.is_running(): + break + + try: + client.connect() + return True + except socket.error: + pass + finally: + client.close() + + time.sleep(poll_interval) + + if not connected: + # There might have been a startup crash of the application + if runner is not None and self.check_for_crash() > 0: + raise IOError("Process crashed (Exit code: {})".format(runner.wait(0))) + + raise socket.timeout( + "Timed out waiting for connection on {0}:{1}!".format( + self.host, self.port + ) + ) + + @do_process_check + def _send_message(self, name, params=None, key=None): + """Send a blocking message to the server. + + Marionette provides an asynchronous, non-blocking interface and + this attempts to paper over this by providing a synchronous API + to the user. + + :param name: Requested command key. + :param params: Optional dictionary of key/value arguments. + :param key: Optional key to extract from response. + + :returns: Full response from the server, or if `key` is given, + the value of said key in the response. + """ + if not self.session_id and name != "WebDriver:NewSession": + raise errors.InvalidSessionIdException("Please start a session") + + try: + msg = self.client.request(name, params) + except IOError: + self.delete_session(send_request=False) + raise + + res, err = msg.result, msg.error + if err: + self._handle_error(err) + + if key is not None: + return self._from_json(res.get(key)) + else: + return self._from_json(res) + + def _handle_error(self, obj): + error = obj["error"] + message = obj["message"] + stacktrace = obj["stacktrace"] + + raise errors.lookup(error)(message, stacktrace=stacktrace) + + def check_for_crash(self): + """Check if the process crashed. + + :returns: True, if a crash happened since the method has been called the last time. + """ + crash_count = 0 + + if self.instance: + name = self.test_name or "marionette.py" + crash_count = self.instance.runner.check_for_crashes(test_name=name) + self.crashed = self.crashed + crash_count + + return crash_count > 0 + + def _handle_socket_failure(self): + """Handle socket failures for the currently connected application. + + If the application crashed then clean-up internal states, or in case of a content + crash also kill the process. If there are other reasons for a socket failure, + wait for the process to shutdown itself, or force kill it. + + Please note that the method expects an exception to be handled on the current stack + frame, and is only called via the `@do_process_check` decorator. + + """ + exc_cls, exc, tb = sys.exc_info() + + # If the application hasn't been launched by Marionette no further action can be done. + # In such cases we simply re-throw the exception. + if not self.instance: + reraise(exc_cls, exc, tb) + + else: + # Somehow the socket disconnected. Give the application some time to shutdown + # itself before killing the process. + returncode = self.instance.runner.wait(timeout=self.shutdown_timeout) + + if returncode is None: + message = ( + "Process killed because the connection to Marionette server is " + "lost. Check gecko.log for errors" + ) + # This will force-close the application without sending any other message. + self.cleanup() + else: + # If Firefox quit itself check if there was a crash + crash_count = self.check_for_crash() + + if crash_count > 0: + # SIGUSR1 indicates a forced shutdown due to a content process crash + if returncode == 245: + message = "Content process crashed" + else: + message = "Process crashed (Exit code: {returncode})" + else: + message = ( + "Process has been unexpectedly closed (Exit code: {returncode})" + ) + + self.delete_session(send_request=False) + + message += " (Reason: {reason})" + + reraise( + IOError, IOError(message.format(returncode=returncode, reason=exc)), tb + ) + + @staticmethod + def convert_keys(*string): + typing = [] + for val in string: + if isinstance(val, Keys): + typing.append(val) + elif isinstance(val, int): + val = str(val) + for i in range(len(val)): + typing.append(val[i]) + else: + for i in range(len(val)): + typing.append(val[i]) + return "".join(typing) + + def clear_pref(self, pref): + """Clear the user-defined value from the specified preference. + + :param pref: Name of the preference. + """ + with self.using_context(self.CONTEXT_CHROME): + self.execute_script( + """ + const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" + ); + Preferences.reset(arguments[0]); + """, + script_args=(pref,), + ) + + def get_pref(self, pref, default_branch=False, value_type="unspecified"): + """Get the value of the specified preference. + + :param pref: Name of the preference. + :param default_branch: Optional, if `True` the preference value will be read + from the default branch. Otherwise the user-defined + value if set is returned. Defaults to `False`. + :param value_type: Optional, XPCOM interface of the pref's complex value. + Possible values are: `nsIFile` and + `nsIPrefLocalizedString`. + + Usage example:: + + marionette.get_pref("browser.tabs.warnOnClose") + + """ + with self.using_context(self.CONTEXT_CHROME): + pref_value = self.execute_script( + """ + const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" + ); + + let pref = arguments[0]; + let defaultBranch = arguments[1]; + let valueType = arguments[2]; + + prefs = new Preferences({defaultBranch: defaultBranch}); + return prefs.get(pref, null, Components.interfaces[valueType]); + """, + script_args=(pref, default_branch, value_type), + ) + return pref_value + + def set_pref(self, pref, value, default_branch=False): + """Set the value of the specified preference. + + :param pref: Name of the preference. + :param value: The value to set the preference to. If the value is None, + reset the preference to its default value. If no default + value exists, the preference will cease to exist. + :param default_branch: Optional, if `True` the preference value will + be written to the default branch, and will remain until + the application gets restarted. Otherwise a user-defined + value is set. Defaults to `False`. + + Usage example:: + + marionette.set_pref("browser.tabs.warnOnClose", True) + + """ + with self.using_context(self.CONTEXT_CHROME): + if value is None: + self.clear_pref(pref) + return + + self.execute_script( + """ + const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" + ); + + let pref = arguments[0]; + let value = arguments[1]; + let defaultBranch = arguments[2]; + + prefs = new Preferences({defaultBranch: defaultBranch}); + prefs.set(pref, value); + """, + script_args=(pref, value, default_branch), + ) + + def set_prefs(self, prefs, default_branch=False): + """Set the value of a list of preferences. + + :param prefs: A dict containing one or more preferences and their values + to be set. See :func:`set_pref` for further details. + :param default_branch: Optional, if `True` the preference value will + be written to the default branch, and will remain until + the application gets restarted. Otherwise a user-defined + value is set. Defaults to `False`. + + Usage example:: + + marionette.set_prefs({"browser.tabs.warnOnClose": True}) + + """ + for pref, value in prefs.items(): + self.set_pref(pref, value, default_branch=default_branch) + + @contextmanager + def using_prefs(self, prefs, default_branch=False): + """Set preferences for code executed in a `with` block, and restores them on exit. + + :param prefs: A dict containing one or more preferences and their values + to be set. See :func:`set_prefs` for further details. + :param default_branch: Optional, if `True` the preference value will + be written to the default branch, and will remain until + the application gets restarted. Otherwise a user-defined + value is set. Defaults to `False`. + + Usage example:: + + with marionette.using_prefs({"browser.tabs.warnOnClose": True}): + # ... do stuff ... + + """ + original_prefs = {p: self.get_pref(p) for p in prefs} + self.set_prefs(prefs, default_branch=default_branch) + + try: + yield + finally: + self.set_prefs(original_prefs, default_branch=default_branch) + + @do_process_check + def enforce_gecko_prefs(self, prefs): + """Checks if the running instance has the given prefs. If not, + it will kill the currently running instance, and spawn a new + instance with the requested preferences. + + :param prefs: A dictionary whose keys are preference names. + """ + if not self.instance: + raise errors.MarionetteException( + "enforce_gecko_prefs() can only be called " + "on Gecko instances launched by Marionette" + ) + pref_exists = True + with self.using_context(self.CONTEXT_CHROME): + for pref, value in six.iteritems(prefs): + if type(value) is not str: + value = json.dumps(value) + pref_exists = self.execute_script( + """ + let prefInterface = Components.classes["@mozilla.org/preferences-service;1"] + .getService(Components.interfaces.nsIPrefBranch); + let pref = '{0}'; + let value = '{1}'; + let type = prefInterface.getPrefType(pref); + switch(type) {{ + case prefInterface.PREF_STRING: + return value == prefInterface.getCharPref(pref).toString(); + case prefInterface.PREF_BOOL: + return value == prefInterface.getBoolPref(pref).toString(); + case prefInterface.PREF_INT: + return value == prefInterface.getIntPref(pref).toString(); + case prefInterface.PREF_INVALID: + return false; + }} + """.format( + pref, value + ) + ) + if not pref_exists: + break + + if not pref_exists: + context = self._send_message("Marionette:GetContext", key="value") + self.delete_session() + self.instance.restart(prefs) + self.raise_for_port() + self.start_session(self.requested_capabilities) + + # Restore the context as used before the restart + self.set_context(context) + + def _request_in_app_shutdown(self, flags=None, safe_mode=False): + """Attempt to quit the currently running instance from inside the + application. If shutdown is prevented by some component the quit + will be forced. + + This method effectively calls `Services.startup.quit` in Gecko. + Possible flag values are listed at https://bit.ly/3IYcjYi. + + :param flags: Optional additional quit masks to include. + + :param safe_mode: Optional flag to indicate that the application has to + be restarted in safe mode. + + :returns: A dictionary containing details of the application shutdown. + The `cause` property reflects the reason, and `forced` indicates + that something prevented the shutdown and the application had + to be forced to shutdown. + + :throws InvalidArgumentException: If there are multiple + `shutdown_flags` ending with `"Quit"`. + """ + body = {} + if flags is not None: + body["flags"] = list( + flags, + ) + if safe_mode: + body["safeMode"] = safe_mode + + return self._send_message("Marionette:Quit", body) + + @do_process_check + def quit(self, clean=False, in_app=True, callback=None): + """ + By default this method will trigger a normal shutdown of the currently running instance. + But it can also be used to force terminate the process. + + This command will delete the active marionette session. It also allows + manipulation of eg. the profile data while the application is not running. + To start the application again, :func:`start_session` has to be called. + + :param clean: If True a new profile will be used after the next start of + the application. Note that the in_app initiated quit always + maintains the same profile. + + :param in_app: If True, marionette will cause a quit from within the + application. Otherwise the application will be restarted + immediately by killing the process. + + :param callback: If provided and `in_app` is True, the callback will + be used to trigger the shutdown. + + :returns: A dictionary containing details of the application shutdown. + The `cause` property reflects the reason, and `forced` indicates + that something prevented the shutdown and the application had + to be forced to shutdown. + """ + if not self.instance: + raise errors.MarionetteException( + "quit() can only be called " "on Gecko instances launched by Marionette" + ) + + quit_details = {"cause": "shutdown", "forced": False} + + if in_app: + if clean: + raise ValueError( + "An in_app restart cannot be triggered with the clean flag set" + ) + + if callback is not None and not callable(callback): + raise ValueError( + "Specified callback '{}' is not callable".format(callback) + ) + + # Block Marionette from accepting new connections + self._send_message("Marionette:AcceptConnections", {"value": False}) + + try: + self.is_shutting_down = True + if callback is not None: + callback() + quit_details["in_app"] = True + else: + quit_details = self._request_in_app_shutdown() + + except IOError: + # A possible IOError should be ignored at this point, given that + # quit() could have been called inside of `using_context`, + # which wants to reset the context but fails sending the message. + pass + + except Exception: + # For any other error assume the application is not going to shutdown. + # As such allow Marionette to accept new connections again. + self.is_shutting_down = False + self._send_message("Marionette:AcceptConnections", {"value": True}) + raise + + try: + self.delete_session(send_request=False) + + # Try to wait for the process to end itself before force-closing it. + returncode = self.instance.runner.wait(timeout=self.shutdown_timeout) + if returncode is None: + self.cleanup() + + message = "Process still running {}s after quit request" + raise IOError(message.format(self.shutdown_timeout)) + + finally: + self.is_shutting_down = False + + else: + self.delete_session(send_request=False) + self.instance.close(clean=clean) + + quit_details.update({"in_app": False, "forced": True}) + + if quit_details.get("cause") not in (None, "shutdown"): + raise errors.MarionetteException( + "Unexpected shutdown reason '{}' for " + "quitting the process.".format(quit_details["cause"]) + ) + + return quit_details + + @do_process_check + def restart( + self, callback=None, clean=False, in_app=True, safe_mode=False, silent=False + ): + """ + By default this method will restart the currently running instance by using the same + profile. But it can also be forced to terminate the currently running instance, and + to spawn a new instance with the same or different profile. + + :param callback: If provided and `in_app` is True, the callback will be + used to trigger the restart. + + :param clean: If True a new profile will be used after the restart. Note + that the in_app initiated restart always maintains the same + profile. + + :param in_app: If True, marionette will cause a restart from within the + application. Otherwise the application will be restarted + immediately by killing the process. + + :param safe_mode: Optional flag to indicate that the application has to + be restarted in safe mode. + + :param silent: Optional flag to indicate that the application should + not open any window after a restart. Note that this flag is only + supported on MacOS and requires "in_app" to be True. + + :returns: A dictionary containing details of the application restart. + The `cause` property reflects the reason, and `forced` indicates + that something prevented the shutdown and the application had + to be forced to shutdown. + """ + if not self.instance: + raise errors.MarionetteException( + "restart() can only be called " + "on Gecko instances launched by Marionette" + ) + + context = self._send_message("Marionette:GetContext", key="value") + restart_details = {"cause": "restart", "forced": False} + + # Safe mode and the silent flag require an in_app restart. + if (safe_mode or silent) and not in_app: + raise ValueError("An in_app restart is required for safe or silent mode") + + if in_app: + if clean: + raise ValueError( + "An in_app restart cannot be triggered with the clean flag set" + ) + + if callback is not None and not callable(callback): + raise ValueError( + "Specified callback '{}' is not callable".format(callback) + ) + + # Block Marionette from accepting new connections + self._send_message("Marionette:AcceptConnections", {"value": False}) + + try: + self.is_shutting_down = True + if callback is not None: + callback() + restart_details["in_app"] = True + else: + flags = ["eRestart"] + if silent: + flags.append("eSilently") + + try: + restart_details = self._request_in_app_shutdown( + flags=flags, safe_mode=safe_mode + ) + except Exception as e: + self._send_message( + "Marionette:AcceptConnections", {"value": True} + ) + raise e + + except IOError: + # A possible IOError should be ignored at this point, given that + # restart() could have been called inside of `using_context`, + # which wants to reset the context but fails sending the message. + pass + + timeout_restart = self.shutdown_timeout + self.startup_timeout + try: + # Wait for a new Marionette connection to appear while the + # process restarts itself. + self.raise_for_port(timeout=timeout_restart, check_process_status=False) + except socket.timeout: + exc_cls, _, tb = sys.exc_info() + + if self.instance.runner.returncode is None: + # The process is still running, which means the shutdown + # request was not correct or the application ignored it. + # Allow Marionette to accept connections again. + self._send_message("Marionette:AcceptConnections", {"value": True}) + + message = "Process still running {}s after restart request" + reraise(exc_cls, exc_cls(message.format(timeout_restart)), tb) + + else: + # The process shutdown but didn't start again. + self.cleanup() + msg = "Process unexpectedly quit without restarting (exit code: {})" + reraise( + exc_cls, + exc_cls(msg.format(self.instance.runner.returncode)), + tb, + ) + + finally: + self.is_shutting_down = False + + self.delete_session(send_request=False) + + else: + self.delete_session() + self.instance.restart(clean=clean) + self.raise_for_port(timeout=self.DEFAULT_STARTUP_TIMEOUT) + + restart_details.update({"in_app": False, "forced": True}) + + if restart_details.get("cause") not in (None, "restart"): + raise errors.MarionetteException( + "Unexpected shutdown reason '{}' for " + "restarting the process".format(restart_details["cause"]) + ) + + self.start_session(self.requested_capabilities) + # Restore the context as used before the restart + self.set_context(context) + + if in_app and self.process_id: + # In some cases Firefox restarts itself by spawning into a new process group. + # As long as mozprocess cannot track that behavior (bug 1284864) we assist by + # informing about the new process id. + self.instance.runner.process_handler.check_for_detached(self.process_id) + + return restart_details + + def absolute_url(self, relative_url): + """ + Returns an absolute url for files served from Marionette's www directory. + + :param relative_url: The url of a static file, relative to Marionette's www directory. + """ + return "{0}{1}".format(self.baseurl, relative_url) + + @do_process_check + def start_session(self, capabilities=None, timeout=None): + """Create a new WebDriver session. + This method must be called before performing any other action. + + :param capabilities: An optional dictionary of + Marionette-recognised capabilities. It does not + accept a WebDriver conforming capabilities dictionary + (including alwaysMatch, firstMatch, desiredCapabilities, + or requriedCapabilities), and only recognises extension + capabilities that are specific to Marionette. + :param timeout: Optional timeout in seconds for the server to be ready. + :returns: A dictionary of the capabilities offered. + """ + if capabilities is None: + capabilities = {"strictFileInteractability": True} + self.requested_capabilities = capabilities + + if timeout is None: + timeout = self.startup_timeout + + self.crashed = 0 + + if self.instance: + returncode = self.instance.runner.returncode + # We're managing a binary which has terminated. Start it again + # and implicitely wait for the Marionette server to be ready. + if returncode is not None: + self.start_binary(timeout) + + else: + # In the case when Marionette doesn't manage the binary wait until + # its server component has been started. + self.raise_for_port(timeout=timeout) + + self.client = transport.TcpTransport(self.host, self.port, self.socket_timeout) + self.protocol, _ = self.client.connect() + + try: + resp = self._send_message("WebDriver:NewSession", capabilities) + except errors.UnknownException: + # Force closing the managed process when the session cannot be + # created due to global JavaScript errors. + exc_type, value, tb = sys.exc_info() + if self.instance and self.instance.runner.is_running(): + self.instance.close() + reraise(exc_type, exc_type(value.message), tb) + + self.session_id = resp["sessionId"] + self.session = resp["capabilities"] + self.cleanup_ran = False + # fallback to processId can be removed in Firefox 55 + self.process_id = self.session.get( + "moz:processID", self.session.get("processId") + ) + self.profile = self.session.get("moz:profile") + + timeout = self.session.get("moz:shutdownTimeout") + if timeout is not None: + # pylint --py3k W1619 + self.shutdown_timeout = timeout / 1000 + 10 + + return self.session + + @property + def test_name(self): + return self._test_name + + @test_name.setter + def test_name(self, test_name): + self._test_name = test_name + + def delete_session(self, send_request=True): + """Close the current session and disconnect from the server. + + :param send_request: Optional, if `True` a request to close the session on + the server side will be sent. Use `False` in case of eg. in_app restart() + or quit(), which trigger a deletion themselves. Defaults to `True`. + """ + try: + if send_request: + try: + self._send_message("WebDriver:DeleteSession") + except errors.InvalidSessionIdException: + pass + finally: + self.process_id = None + self.profile = None + self.session = None + self.session_id = None + self.window = None + + if self.client is not None: + self.client.close() + + @property + def session_capabilities(self): + """A JSON dictionary representing the capabilities of the + current session. + + """ + return self.session + + @property + def current_window_handle(self): + """Get the current window's handle. + + Returns an opaque server-assigned identifier to this window + that uniquely identifies it within this Marionette instance. + This can be used to switch to this window at a later point. + + :returns: unique window handle + :rtype: string + """ + with self.using_context("content"): + self.window = self._send_message("WebDriver:GetWindowHandle", key="value") + + return self.window + + @property + def current_chrome_window_handle(self): + """Get the current chrome window's handle. Corresponds to + a chrome window that may itself contain tabs identified by + window_handles. + + Returns an opaque server-assigned identifier to this window + that uniquely identifies it within this Marionette instance. + This can be used to switch to this window at a later point. + + :returns: unique window handle + :rtype: string + """ + with self.using_context("chrome"): + self.chrome_window = self._send_message( + "WebDriver:GetWindowHandle", key="value" + ) + + return self.chrome_window + + def set_window_rect(self, x=None, y=None, height=None, width=None): + """Set the position and size of the current window. + + The supplied width and height values refer to the window outerWidth + and outerHeight values, which include scroll bars, title bars, etc. + + An error will be returned if the requested window size would result + in the window being in the maximised state. + + :param x: x coordinate for the top left of the window + :param y: y coordinate for the top left of the window + :param width: The width to resize the window to. + :param height: The height to resize the window to. + """ + if (x is None and y is None) and (height is None and width is None): + raise errors.InvalidArgumentException( + "x and y or height and width need values" + ) + + body = {"x": x, "y": y, "height": height, "width": width} + return self._send_message("WebDriver:SetWindowRect", body) + + @property + def window_rect(self): + return self._send_message("WebDriver:GetWindowRect") + + @property + def title(self): + """Current title of the active window.""" + return self._send_message("WebDriver:GetTitle", key="value") + + @property + def window_handles(self): + """Get list of windows in the current context. + + If called in the content context it will return a list of + references to all available browser windows. + + Each window handle is assigned by the server, and the list of + strings returned does not have a guaranteed ordering. + + :returns: Unordered list of unique window handles as strings + """ + with self.using_context("content"): + return self._send_message("WebDriver:GetWindowHandles") + + @property + def chrome_window_handles(self): + """Get a list of currently open chrome windows. + + Each window handle is assigned by the server, and the list of + strings returned does not have a guaranteed ordering. + + :returns: Unordered list of unique chrome window handles as strings + """ + with self.using_context("chrome"): + return self._send_message("WebDriver:GetWindowHandles") + + @property + def page_source(self): + """A string representation of the DOM.""" + return self._send_message("WebDriver:GetPageSource", key="value") + + def open(self, type=None, focus=False, private=False): + """Open a new window, or tab based on the specified context type. + + If no context type is given the application will choose the best + option based on tab and window support. + + :param type: Type of window to be opened. Can be one of "tab" or "window" + :param focus: If true, the opened window will be focused + :param private: If true, open a private window + + :returns: Dict with new window handle, and type of opened window + """ + body = {"type": type, "focus": focus, "private": private} + return self._send_message("WebDriver:NewWindow", body) + + def close(self): + """Close the current window, ending the session if it's the last + window currently open. + + :returns: Unordered list of remaining unique window handles as strings + """ + return self._send_message("WebDriver:CloseWindow") + + def close_chrome_window(self): + """Close the currently selected chrome window, ending the session + if it's the last window open. + + :returns: Unordered list of remaining unique chrome window handles as strings + """ + return self._send_message("WebDriver:CloseChromeWindow") + + def set_context(self, context): + """Sets the context that Marionette commands are running in. + + :param context: Context, may be one of the class properties + `CONTEXT_CHROME` or `CONTEXT_CONTENT`. + + Usage example:: + + marionette.set_context(marionette.CONTEXT_CHROME) + """ + if context not in [self.CONTEXT_CHROME, self.CONTEXT_CONTENT]: + raise ValueError("Unknown context: {}".format(context)) + + self._send_message("Marionette:SetContext", {"value": context}) + + @contextmanager + def using_context(self, context): + """Sets the context that Marionette commands are running in using + a `with` statement. The state of the context on the server is + saved before entering the block, and restored upon exiting it. + + :param context: Context, may be one of the class properties + `CONTEXT_CHROME` or `CONTEXT_CONTENT`. + + Usage example:: + + with marionette.using_context(marionette.CONTEXT_CHROME): + # chrome scope + ... do stuff ... + """ + scope = self._send_message("Marionette:GetContext", key="value") + self.set_context(context) + try: + yield + finally: + self.set_context(scope) + + def switch_to_alert(self): + """Returns an :class:`~marionette_driver.marionette.Alert` object for + interacting with a currently displayed alert. + + :: + + alert = self.marionette.switch_to_alert() + text = alert.text + alert.accept() + """ + return Alert(self) + + def switch_to_window(self, handle, focus=True): + """Switch to the specified window; subsequent commands will be + directed at the new window. + + :param handle: The id of the window to switch to. + + :param focus: A boolean value which determins whether to focus + the window that we just switched to. + """ + self._send_message( + "WebDriver:SwitchToWindow", {"handle": handle, "focus": focus} + ) + self.window = handle + + def switch_to_default_content(self): + """Switch the current context to page's default content.""" + return self.switch_to_frame() + + def switch_to_parent_frame(self): + """ + Switch to the Parent Frame + """ + self._send_message("WebDriver:SwitchToParentFrame") + + def switch_to_frame(self, frame=None): + """Switch the current context to the specified frame. Subsequent + commands will operate in the context of the specified frame, + if applicable. + + :param frame: A reference to the frame to switch to. This can + be an :class:`~marionette_driver.marionette.WebElement`, + or an integer index. If you call ``switch_to_frame`` without an + argument, it will switch to the top-level frame. + """ + body = {} + if isinstance(frame, WebElement): + body["element"] = frame.id + elif frame is not None: + body["id"] = frame + + self._send_message("WebDriver:SwitchToFrame", body) + + def get_url(self): + """Get a string representing the current URL. + + On Desktop this returns a string representation of the URL of + the current top level browsing context. This is equivalent to + document.location.href. + + When in the context of the chrome, this returns the canonical + URL of the current resource. + + :returns: string representation of URL + """ + return self._send_message("WebDriver:GetCurrentURL", key="value") + + def get_window_type(self): + """Gets the windowtype attribute of the window Marionette is + currently acting on. + + This command only makes sense in a chrome context. You might use this + method to distinguish a browser window from an editor window. + """ + try: + return self._send_message("Marionette:GetWindowType", key="value") + except errors.UnknownCommandException: + return self._send_message("getWindowType", key="value") + + def navigate(self, url): + """Navigate to given `url`. + + Navigates the current top-level browsing context's content + frame to the given URL and waits for the document to load or + the session's page timeout duration to elapse before returning. + + The command will return with a failure if there is an error + loading the document or the URL is blocked. This can occur if + it fails to reach the host, the URL is malformed, the page is + restricted (about:* pages), or if there is a certificate issue + to name some examples. + + The document is considered successfully loaded when the + `DOMContentLoaded` event on the frame element associated with the + `window` triggers and `document.readyState` is "complete". + + In chrome context it will change the current `window`'s location + to the supplied URL and wait until `document.readyState` equals + "complete" or the page timeout duration has elapsed. + + :param url: The URL to navigate to. + """ + self._send_message("WebDriver:Navigate", {"url": url}) + + def go_back(self): + """Causes the browser to perform a back navigation.""" + self._send_message("WebDriver:Back") + + def go_forward(self): + """Causes the browser to perform a forward navigation.""" + self._send_message("WebDriver:Forward") + + def refresh(self): + """Causes the browser to perform to refresh the current page.""" + self._send_message("WebDriver:Refresh") + + def _to_json(self, args): + if isinstance(args, (list, 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) == WebElement: + wrapped = {WEB_ELEMENT_KEY: args.id} + elif type(args) == ShadowRoot: + wrapped = {WEB_SHADOW_ROOT_KEY: args.id} + elif type(args) == WebFrame: + wrapped = {WEB_FRAME_KEY: args.id} + elif type(args) == WebWindow: + wrapped = {WEB_WINDOW_KEY: args.id} + elif isinstance(args, (bool, int, float, six.string_types)) or args is None: + wrapped = args + return wrapped + + def _from_json(self, value): + if isinstance(value, dict) and any( + k in value.keys() for k in WebElement.identifiers + ): + return WebElement._from_json(value, self) + elif isinstance(value, dict) and any( + k in value.keys() for k in ShadowRoot.identifiers + ): + return ShadowRoot._from_json(value, self) + elif isinstance(value, dict) and any( + k in value.keys() for k in WebFrame.identifiers + ): + return WebFrame._from_json(value, self) + elif isinstance(value, dict) and any( + k in value.keys() for k in WebWindow.identifiers + ): + return WebWindow._from_json(value, self) + elif isinstance(value, dict): + return {key: self._from_json(val) for key, val in value.items()} + elif isinstance(value, list): + return list(self._from_json(item) for item in value) + else: + return value + + def execute_script( + self, + script, + script_args=(), + new_sandbox=True, + sandbox="default", + script_timeout=None, + ): + """Executes a synchronous JavaScript script, and returns the + result (or None if the script does return a value). + + The script is executed in the context set by the most recent + :func:`set_context` call, or to the CONTEXT_CONTENT context if + :func:`set_context` has not been called. + + :param script: A string containing the JavaScript to execute. + :param script_args: An interable of arguments to pass to the script. + :param new_sandbox: If False, preserve global variables from + the last execute_*script call. This is True by default, in which + case no globals are preserved. + :param sandbox: A tag referring to the sandbox you wish to use; + if you specify a new tag, a new sandbox will be created. + If you use the special tag `system`, the sandbox will + be created using the system principal which has elevated + privileges. + :param script_timeout: Timeout in milliseconds, overriding + the session's default script timeout. + + Simple usage example: + + :: + + result = marionette.execute_script("return 1;") + assert result == 1 + + You can use the `script_args` parameter to pass arguments to the + script: + + :: + + result = marionette.execute_script("return arguments[0] + arguments[1];", + script_args=(2, 3,)) + assert result == 5 + some_element = marionette.find_element(By.ID, "someElement") + sid = marionette.execute_script("return arguments[0].id;", script_args=(some_element,)) + assert some_element.get_attribute("id") == sid + + Scripts wishing to access non-standard properties of the window + object must use window.wrappedJSObject: + + :: + + result = marionette.execute_script(''' + window.wrappedJSObject.test1 = "foo"; + window.wrappedJSObject.test2 = "bar"; + return window.wrappedJSObject.test1 + window.wrappedJSObject.test2; + ''') + assert result == "foobar" + + Global variables set by individual scripts do not persist between + script calls by default. If you wish to persist data between + script calls, you can set `new_sandbox` to False on your next call, + and add any new variables to a new 'global' object like this: + + :: + + marionette.execute_script("global.test1 = 'foo';") + result = self.marionette.execute_script("return global.test1;", new_sandbox=False) + assert result == "foo" + + """ + original_timeout = None + if script_timeout is not None: + original_timeout = self.timeout.script + self.timeout.script = script_timeout / 1000.0 + + try: + args = self._to_json(script_args) + stack = traceback.extract_stack() + frame = stack[-2:-1][0] # grab the second-to-last frame + filename = ( + frame[0] if sys.platform == "win32" else os.path.relpath(frame[0]) + ) + body = { + "script": script.strip(), + "args": args, + "newSandbox": new_sandbox, + "sandbox": sandbox, + "line": int(frame[1]), + "filename": filename, + } + rv = self._send_message("WebDriver:ExecuteScript", body, key="value") + + finally: + if script_timeout is not None: + self.timeout.script = original_timeout + + return rv + + def execute_async_script( + self, + script, + script_args=(), + new_sandbox=True, + sandbox="default", + script_timeout=None, + ): + """Executes an asynchronous JavaScript script, and returns the + result (or None if the script does return a value). + + The script is executed in the context set by the most recent + :func:`set_context` call, or to the CONTEXT_CONTENT context if + :func:`set_context` has not been called. + + :param script: A string containing the JavaScript to execute. + :param script_args: An interable of arguments to pass to the script. + :param new_sandbox: If False, preserve global variables from + the last execute_*script call. This is True by default, + in which case no globals are preserved. + :param sandbox: A tag referring to the sandbox you wish to use; if + you specify a new tag, a new sandbox will be created. If you + use the special tag `system`, the sandbox will be created + using the system principal which has elevated privileges. + :param script_timeout: Timeout in milliseconds, overriding + the session's default script timeout. + + Usage example: + + :: + + marionette.timeout.script = 10 + result = self.marionette.execute_async_script(''' + // this script waits 5 seconds, and then returns the number 1 + let [resolve] = arguments; + setTimeout(function() { + resolve(1); + }, 5000); + ''') + assert result == 1 + """ + original_timeout = None + if script_timeout is not None: + original_timeout = self.timeout.script + self.timeout.script = script_timeout / 1000.0 + + try: + args = self._to_json(script_args) + stack = traceback.extract_stack() + frame = stack[-2:-1][0] # grab the second-to-last frame + filename = ( + frame[0] if sys.platform == "win32" else os.path.relpath(frame[0]) + ) + body = { + "script": script.strip(), + "args": args, + "newSandbox": new_sandbox, + "sandbox": sandbox, + "scriptTimeout": script_timeout, + "line": int(frame[1]), + "filename": filename, + } + rv = self._send_message("WebDriver:ExecuteAsyncScript", body, key="value") + + finally: + if script_timeout is not None: + self.timeout.script = original_timeout + + return rv + + def find_element(self, method, target, id=None): + """Returns an :class:`~marionette_driver.marionette.WebElement` + instance that matches the specified method and target in the current + context. + + An :class:`~marionette_driver.marionette.WebElement` instance may be + used to call other methods on the element, such as + :func:`~marionette_driver.marionette.WebElement.click`. If no element + is immediately found, the attempt to locate an element will be repeated + for up to the amount of time set by + :attr:`marionette_driver.timeout.Timeouts.implicit`. If multiple + elements match the given criteria, only the first is returned. If no + element matches, a ``NoSuchElementException`` will be raised. + + :param method: The method to use to locate the element; one of: + "id", "name", "class name", "tag name", "css selector", + "link text", "partial link text" and "xpath". + Note that the "name", "link text" and "partial link test" + methods are not supported in the chrome DOM. + :param target: The target of the search. For example, if method = + "tag", target might equal "div". If method = "id", target would + be an element id. + :param id: If specified, search for elements only inside the element + with the specified id. + """ + body = {"value": target, "using": method} + if id: + body["element"] = id + + return self._send_message("WebDriver:FindElement", body, key="value") + + def find_elements(self, method, target, id=None): + """Returns a list of all + :class:`~marionette_driver.marionette.WebElement` instances that match + the specified method and target in the current context. + + An :class:`~marionette_driver.marionette.WebElement` instance may be + used to call other methods on the element, such as + :func:`~marionette_driver.marionette.WebElement.click`. If no element + is immediately found, the attempt to locate an element will be repeated + for up to the amount of time set by + :attr:`marionette_driver.timeout.Timeouts.implicit`. + + :param method: The method to use to locate the elements; one + of: "id", "name", "class name", "tag name", "css selector", + "link text", "partial link text" and "xpath". + Note that the "name", "link text" and "partial link test" + methods are not supported in the chrome DOM. + :param target: The target of the search. For example, if method = + "tag", target might equal "div". If method = "id", target would be + an element id. + :param id: If specified, search for elements only inside the element + with the specified id. + """ + body = {"value": target, "using": method} + if id: + body["element"] = id + + return self._send_message("WebDriver:FindElements", body) + + def get_active_element(self): + el_or_ref = self._send_message("WebDriver:GetActiveElement", key="value") + return el_or_ref + + def add_cookie(self, cookie): + """Adds a cookie to your current session. + + :param cookie: A dictionary object, with required keys - "name" + and "value"; optional keys - "path", "domain", "secure", + "expiry". + + Usage example: + + :: + + driver.add_cookie({"name": "foo", "value": "bar"}) + driver.add_cookie({"name": "foo", "value": "bar", "path": "/"}) + driver.add_cookie({"name": "foo", "value": "bar", "path": "/", + "secure": True}) + """ + self._send_message("WebDriver:AddCookie", {"cookie": cookie}) + + def delete_all_cookies(self): + """Delete all cookies in the scope of the current session. + + Usage example: + + :: + + driver.delete_all_cookies() + """ + self._send_message("WebDriver:DeleteAllCookies") + + def delete_cookie(self, name): + """Delete a cookie by its name. + + :param name: Name of cookie to delete. + + Usage example: + + :: + + driver.delete_cookie("foo") + """ + self._send_message("WebDriver:DeleteCookie", {"name": name}) + + def get_cookie(self, name): + """Get a single cookie by name. Returns the cookie if found, + None if not. + + :param name: Name of cookie to get. + """ + cookies = self.get_cookies() + for cookie in cookies: + if cookie["name"] == name: + return cookie + return None + + def get_cookies(self): + """Get all the cookies for the current domain. + + This is the equivalent of calling `document.cookie` and + parsing the result. + + :returns: A list of cookies for the current domain. + """ + return self._send_message("WebDriver:GetCookies") + + def save_screenshot(self, fh, element=None, full=True, scroll=True): + """Takes a screenhot of a web element or the current frame and + saves it in the filehandle. + + It is a wrapper around screenshot() + :param fh: The filehandle to save the screenshot at. + + The rest of the parameters are defined like in screenshot() + """ + data = self.screenshot(element, "binary", full, scroll) + fh.write(data) + + def screenshot(self, element=None, format="base64", full=True, scroll=True): + """Takes a screenshot of a web element or the current frame. + + The screen capture is returned as a lossless PNG image encoded + as a base 64 string by default. If the `element` argument is defined the + capture area will be limited to the bounding box of that + element. Otherwise, the capture area will be the bounding box + of the current frame. + + :param element: The element to take a screenshot of. If None, will + take a screenshot of the current frame. + + :param format: if "base64" (the default), returns the screenshot + as a base64-string. If "binary", the data is decoded and + returned as raw binary. If "hash", the data is hashed using + the SHA-256 algorithm and the result is returned as a hex digest. + + :param full: If True (the default), the capture area will be the + complete frame. Else only the viewport is captured. Only applies + when `element` is None. + + :param scroll: When `element` is provided, scroll to it before + taking the screenshot (default). Otherwise, avoid scrolling + `element` into view. + """ + + if element: + element = element.id + + body = {"id": element, "full": full, "hash": False, "scroll": scroll} + if format == "hash": + body["hash"] = True + + data = self._send_message("WebDriver:TakeScreenshot", body, key="value") + + if format == "base64" or format == "hash": + return data + elif format == "binary": + return base64.b64decode(data.encode("ascii")) + else: + raise ValueError( + "format parameter must be either 'base64'" + " or 'binary', not {0}".format(repr(format)) + ) + + @property + def orientation(self): + """Get the current browser orientation. + + Will return one of the valid primary orientation values + portrait-primary, landscape-primary, portrait-secondary, or + landscape-secondary. + """ + try: + return self._send_message("Marionette:GetScreenOrientation", key="value") + except errors.UnknownCommandException: + return self._send_message("getScreenOrientation", key="value") + + def set_orientation(self, orientation): + """Set the current browser orientation. + + The supplied orientation should be given as one of the valid + orientation values. If the orientation is unknown, an error + will be raised. + + Valid orientations are "portrait" and "landscape", which fall + back to "portrait-primary" and "landscape-primary" + respectively, and "portrait-secondary" as well as + "landscape-secondary". + + :param orientation: The orientation to lock the screen in. + """ + body = {"orientation": orientation} + try: + self._send_message("Marionette:SetScreenOrientation", body) + except errors.UnknownCommandException: + self._send_message("setScreenOrientation", body) + + def minimize_window(self): + """Iconify the browser window currently receiving commands. + The action should be equivalent to the user pressing the minimize + button in the OS window. + + Note that this command is not available on Fennec. It may also + not be available in certain window managers. + + :returns Window rect. + """ + return self._send_message("WebDriver:MinimizeWindow") + + def maximize_window(self): + """Resize the browser window currently receiving commands. + The action should be equivalent to the user pressing the maximize + button in the OS window. + + + Note that this command is not available on Fennec. It may also + not be available in certain window managers. + + :returns: Window rect. + """ + return self._send_message("WebDriver:MaximizeWindow") + + def fullscreen(self): + """Synchronously sets the user agent window to full screen as + if the user had done "View > Enter Full Screen", or restores + it if it is already in full screen. + + :returns: Window rect. + """ + return self._send_message("WebDriver:FullscreenWindow") diff --git a/testing/marionette/client/marionette_driver/timeout.py b/testing/marionette/client/marionette_driver/timeout.py new file mode 100644 index 0000000000..27848d0121 --- /dev/null +++ b/testing/marionette/client/marionette_driver/timeout.py @@ -0,0 +1,103 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from . import errors + +DEFAULT_SCRIPT_TIMEOUT = 30 +DEFAULT_PAGE_LOAD_TIMEOUT = 300 +DEFAULT_IMPLICIT_WAIT_TIMEOUT = 0 + + +class Timeouts(object): + """Manage timeout settings in the Marionette session. + + Usage:: + + marionette = Marionette(...) + marionette.start_session() + marionette.timeout.page_load = 10 + marionette.timeout.page_load + # => 10 + + """ + + def __init__(self, marionette): + self._marionette = marionette + + def _set(self, name, sec): + ms = sec * 1000 + self._marionette._send_message("WebDriver:SetTimeouts", {name: ms}) + + def _get(self, name): + ts = self._marionette._send_message("WebDriver:GetTimeouts") + if name not in ts: + raise KeyError() + ms = ts[name] + return ms / 1000.0 + + @property + def script(self): + """Get the session's script timeout. This specifies the time + to wait for injected scripts to finished before interrupting + them. It is by default 30 seconds. + + """ + return self._get("script") + + @script.setter + def script(self, sec): + """Set the session's script timeout. This specifies the time + to wait for injected scripts to finish before interrupting them. + + """ + self._set("script", sec) + + @property + def page_load(self): + """Get the session's page load timeout. This specifies the time + to wait for the page loading to complete. It is by default 5 + minutes (or 300 seconds). + + """ + # remove fallback when Firefox 56 is stable + try: + return self._get("pageLoad") + except KeyError: + return self._get("page load") + + @page_load.setter + def page_load(self, sec): + """Set the session's page load timeout. This specifies the time + to wait for the page loading to complete. + + """ + # remove fallback when Firefox 56 is stable + try: + self._set("pageLoad", sec) + except errors.InvalidArgumentException: + return self._set("page load", sec) + + @property + def implicit(self): + """Get the session's implicit wait timeout. This specifies the + time to wait for the implicit element location strategy when + retrieving elements. It is by default disabled (0 seconds). + + """ + return self._get("implicit") + + @implicit.setter + def implicit(self, sec): + """Set the session's implicit wait timeout. This specifies the + time to wait for the implicit element location strategy when + retrieving elements. + + """ + self._set("implicit", sec) + + def reset(self): + """Resets timeouts to their default values.""" + self.script = DEFAULT_SCRIPT_TIMEOUT + self.page_load = DEFAULT_PAGE_LOAD_TIMEOUT + self.implicit = DEFAULT_IMPLICIT_WAIT_TIMEOUT diff --git a/testing/marionette/client/marionette_driver/transport.py b/testing/marionette/client/marionette_driver/transport.py new file mode 100644 index 0000000000..cbaac8ea2c --- /dev/null +++ b/testing/marionette/client/marionette_driver/transport.py @@ -0,0 +1,409 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import json +import socket +import sys +import time +from threading import RLock + +import six + + +class SocketTimeout(object): + def __init__(self, socket_ctx, timeout): + self.socket_ctx = socket_ctx + self.timeout = timeout + self.old_timeout = None + + def __enter__(self): + self.old_timeout = self.socket_ctx.socket_timeout + self.socket_ctx.socket_timeout = self.timeout + + def __exit__(self, *args, **kwargs): + self.socket_ctx.socket_timeout = self.old_timeout + + +class Message(object): + def __init__(self, msgid): + self.id = msgid + + def __eq__(self, other): + return self.id == other.id + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + # pylint --py3k: W1641 + return hash(self.id) + + +class Command(Message): + TYPE = 0 + + def __init__(self, msgid, name, params): + Message.__init__(self, msgid) + self.name = name + self.params = params + + def __str__(self): + return "<Command id={0}, name={1}, params={2}>".format( + self.id, self.name, self.params + ) + + def to_msg(self): + msg = [Command.TYPE, self.id, self.name, self.params] + return json.dumps(msg) + + @staticmethod + def from_msg(data): + assert data[0] == Command.TYPE + cmd = Command(data[1], data[2], data[3]) + return cmd + + +class Response(Message): + TYPE = 1 + + def __init__(self, msgid, error, result): + Message.__init__(self, msgid) + self.error = error + self.result = result + + def __str__(self): + return "<Response id={0}, error={1}, result={2}>".format( + self.id, self.error, self.result + ) + + def to_msg(self): + msg = [Response.TYPE, self.id, self.error, self.result] + return json.dumps(msg) + + @staticmethod + def from_msg(data): + assert data[0] == Response.TYPE + return Response(data[1], data[2], data[3]) + + +class SocketContext(object): + """Object that guards access to a socket via a lock. + + The socket must be accessed using this object as a context manager; + access to the socket outside of a context will bypass the lock.""" + + def __init__(self, host, port, timeout): + self.lock = RLock() + + self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock.settimeout(timeout) + self._sock.connect((host, port)) + + @property + def socket_timeout(self): + return self._sock.gettimeout() + + @socket_timeout.setter + def socket_timeout(self, value): + self._sock.settimeout(value) + + def __enter__(self): + self.lock.acquire() + return self._sock + + def __exit__(self, *args, **kwargs): + self.lock.release() + + +class TcpTransport(object): + """Socket client that communciates with Marionette via TCP. + + It speaks the protocol of the remote debugger in Gecko, in which + messages are always preceded by the message length and a colon, e.g.: + + 7:MESSAGE + + On top of this protocol it uses a Marionette message format, that + depending on the protocol level offered by the remote server, varies. + Supported protocol levels are `min_protocol_level` and above. + """ + + max_packet_length = 4096 + min_protocol_level = 3 + + def __init__(self, host, port, socket_timeout=60.0): + """If `socket_timeout` is `0` or `0.0`, non-blocking socket mode + will be used. Setting it to `1` or `None` disables timeouts on + socket operations altogether. + """ + self._socket_context = None + + self.host = host + self.port = port + self._socket_timeout = socket_timeout + + self.protocol = self.min_protocol_level + self.application_type = None + self.last_id = 0 + self.expected_response = None + + @property + def socket_timeout(self): + return self._socket_timeout + + @socket_timeout.setter + def socket_timeout(self, value): + self._socket_timeout = value + + if self._socket_context is not None: + self._socket_context.socket_timeout = value + + def _unmarshal(self, packet): + """Convert data from bytes to a Message subtype + + Message format is [type, msg_id, body1, body2], where body1 and body2 depend + on the message type. + + :param packet: Bytes received over the wire representing a complete message. + """ + msg = None + + data = json.loads(packet) + msg_type = data[0] + + if msg_type == Command.TYPE: + msg = Command.from_msg(data) + elif msg_type == Response.TYPE: + msg = Response.from_msg(data) + else: + raise ValueError("Invalid message body {!r}".format(packet)) + + return msg + + def receive(self, unmarshal=True): + """Wait for the next complete response from the remote. + + Packet format is length-prefixed JSON: + + packet = digit+ ":" body + digit = "0"-"9" + body = JSON text + + :param unmarshal: Default is to deserialise the packet and + return a ``Message`` type. Setting this to false will return + the raw packet. + """ + # Initally we read 4 bytes. We don't support reading beyond the end of a message, and + # so assuming the JSON body has to be an array or object, the minimum possible message + # is 4 bytes: "2:{}". In practice the marionette format has some required fields so the + # message is longer, but 4 bytes allows reading messages with bodies up to 999 bytes in + # length in two reads, which is the common case. + with self._socket_context as sock: + recv_bytes = 4 + + length_prefix = b"" + + body_length = -1 + body_received = 0 + body_parts = [] + + now = time.time() + timeout_time = ( + now + self.socket_timeout if self.socket_timeout is not None else None + ) + + while recv_bytes > 0: + if timeout_time is not None and time.time() > timeout_time: + raise socket.timeout( + "Connection timed out after {}s".format(self.socket_timeout) + ) + + try: + chunk = sock.recv(recv_bytes) + except socket.timeout: + # Lets handle it with our own timeout check + continue + + if not chunk: + raise socket.error("No data received over socket") + + body_part = None + if body_length > 0: + body_part = chunk + else: + parts = chunk.split(b":", 1) + length_prefix += parts[0] + + # With > 10 decimal digits we aren't going to have a 32 bit number + if len(length_prefix) > 10: + raise ValueError( + "Invalid message length: {!r}".format(length_prefix) + ) + + if len(parts) == 2: + # We found a : so we know the full length + err = None + try: + body_length = int(length_prefix) + except ValueError: + err = "expected an integer" + else: + if body_length <= 0: + err = "expected a positive integer" + elif body_length > 2**32 - 1: + err = "expected a 32 bit integer" + if err is not None: + raise ValueError( + "Invalid message length: {} got {!r}".format( + err, length_prefix + ) + ) + body_part = parts[1] + + # If we didn't find a : yet we keep reading 4 bytes at a time until we do. + # We could increase this here to 7 bytes (since we can't have more than 10 + # length bytes and a seperator byte), or just increase it to + # int(length_prefix) + 1 since that's the minimum total number of remaining + # bytes (if the : is in the next byte), but it's probably not worth optimising + # for large messages. + + if body_part is not None: + body_received += len(body_part) + body_parts.append(body_part) + recv_bytes = body_length - body_received + + body = b"".join(body_parts) + if unmarshal: + msg = self._unmarshal(body) + self.last_id = msg.id + + # keep reading incoming responses until + # we receive the user's expected response + if isinstance(msg, Response) and msg != self.expected_response: + return self.receive(unmarshal) + + return msg + return body + + def connect(self): + """Connect to the server and process the hello message we expect + to receive in response. + + Returns a tuple of the protocol level and the application type. + """ + try: + self._socket_context = SocketContext( + self.host, self.port, self._socket_timeout + ) + except Exception: + # Unset so that the next attempt to send will cause + # another connection attempt. + self._socket_context = None + raise + + try: + with SocketTimeout(self._socket_context, 60.0): + # first packet is always a JSON Object + # which we can use to tell which protocol level we are at + raw = self.receive(unmarshal=False) + except socket.timeout: + exc_cls, exc, tb = sys.exc_info() + msg = "Connection attempt failed because no data has been received over the socket: {}" + six.reraise(exc_cls, exc_cls(msg.format(exc)), tb) + + hello = json.loads(raw) + application_type = hello.get("applicationType") + protocol = hello.get("marionetteProtocol") + + if application_type != "gecko": + raise ValueError( + "Application type '{}' is not supported".format(application_type) + ) + + if not isinstance(protocol, int) or protocol < self.min_protocol_level: + msg = "Earliest supported protocol level is '{}' but got '{}'" + raise ValueError(msg.format(self.min_protocol_level, protocol)) + + self.application_type = application_type + self.protocol = protocol + + return (self.protocol, self.application_type) + + def send(self, obj): + """Send message to the remote server. Allowed input is a + ``Message`` instance or a JSON serialisable object. + """ + if not self._socket_context: + self.connect() + + if isinstance(obj, Message): + data = obj.to_msg() + if isinstance(obj, Command): + self.expected_response = obj + else: + data = json.dumps(obj) + data = six.ensure_binary(data) + payload = six.ensure_binary(str(len(data))) + b":" + data + + with self._socket_context as sock: + totalsent = 0 + while totalsent < len(payload): + sent = sock.send(payload[totalsent:]) + if sent == 0: + raise IOError( + "Socket error after sending {0} of {1} bytes".format( + totalsent, len(payload) + ) + ) + else: + totalsent += sent + + def respond(self, obj): + """Send a response to a command. This can be an arbitrary JSON + serialisable object or an ``Exception``. + """ + res, err = None, None + if isinstance(obj, Exception): + err = obj + else: + res = obj + msg = Response(self.last_id, err, res) + self.send(msg) + return self.receive() + + def request(self, name, params): + """Sends a message to the remote server and waits for a response + to come back. + """ + self.last_id = self.last_id + 1 + cmd = Command(self.last_id, name, params) + self.send(cmd) + return self.receive() + + def close(self): + """Close the socket. + + First forces the socket to not send data anymore, and then explicitly + close it to free up its resources. + + See: https://docs.python.org/2/howto/sockets.html#disconnecting + """ + if self._socket_context: + with self._socket_context as sock: + try: + sock.shutdown(socket.SHUT_RDWR) + except IOError as exc: + # If the socket is already closed, don't care about: + # Errno 57: Socket not connected + # Errno 107: Transport endpoint is not connected + if exc.errno not in (57, 107): + raise + + if sock: + # Guard against unclean shutdown. + sock.close() + self._socket_context = None + + def __del__(self): + self.close() diff --git a/testing/marionette/client/marionette_driver/wait.py b/testing/marionette/client/marionette_driver/wait.py new file mode 100644 index 0000000000..bc34ccf4cb --- /dev/null +++ b/testing/marionette/client/marionette_driver/wait.py @@ -0,0 +1,175 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import collections +import sys +import time + +from . import errors + +DEFAULT_TIMEOUT = 5 +DEFAULT_INTERVAL = 0.1 + + +class Wait(object): + + """An explicit conditional utility class for waiting until a condition + evaluates to true or not null. + + This will repeatedly evaluate a condition in anticipation for a + truthy return value, or its timeout to expire, or its waiting + predicate to become true. + + A `Wait` instance defines the maximum amount of time to wait for a + condition, as well as the frequency with which to check the + condition. Furthermore, the user may configure the wait to ignore + specific types of exceptions whilst waiting, such as + `errors.NoSuchElementException` when searching for an element on + the page. + + """ + + def __init__( + self, + marionette, + timeout=None, + interval=None, + ignored_exceptions=None, + clock=None, + ): + """Configure the Wait instance to have a custom timeout, interval, and + list of ignored exceptions. Optionally a different time + implementation than the one provided by the standard library + (time) can also be provided. + + Sample usage:: + + # Wait 30 seconds for window to open, checking for its presence once + # every 5 seconds. + wait = Wait(marionette, timeout=30, interval=5, + ignored_exceptions=errors.NoSuchWindowException) + window = wait.until(lambda m: m.switch_to_window(42)) + + :param marionette: The input value to be provided to + conditions, usually a Marionette instance. + + :param timeout: How long to wait for the evaluated condition + to become true. The default timeout is + `wait.DEFAULT_TIMEOUT`. + + :param interval: How often the condition should be evaluated. + In reality the interval may be greater as the cost of + evaluating the condition function. If that is not the case the + interval for the next condition function call is shortend to keep + the original interval sequence as best as possible. + The default polling interval is `wait.DEFAULT_INTERVAL`. + + :param ignored_exceptions: Ignore specific types of exceptions + whilst waiting for the condition. Any exceptions not + whitelisted will be allowed to propagate, terminating the + wait. + + :param clock: Allows overriding the use of the runtime's + default time library. See `wait.SystemClock` for + implementation details. + + """ + + self.marionette = marionette + self.timeout = timeout if timeout is not None else DEFAULT_TIMEOUT + self.interval = interval if interval is not None else DEFAULT_INTERVAL + self.clock = clock or SystemClock() + self.end = self.clock.now + self.timeout + + exceptions = [] + if ignored_exceptions is not None: + if isinstance(ignored_exceptions, collections.abc.Iterable): + exceptions.extend(iter(ignored_exceptions)) + else: + exceptions.append(ignored_exceptions) + self.exceptions = tuple(set(exceptions)) + + def until(self, condition, is_true=None, message=""): + """Repeatedly runs condition until its return value evaluates to true, + or its timeout expires or the predicate evaluates to true. + + This will poll at the given interval until the given timeout + is reached, or the predicate or conditions returns true. A + condition that returns null or does not evaluate to true will + fully elapse its timeout before raising an + `errors.TimeoutException`. + + If an exception is raised in the condition function and it's + not ignored, this function will raise immediately. If the + exception is ignored, it will continue polling for the + condition until it returns successfully or a + `TimeoutException` is raised. + + :param condition: A callable function whose return value will + be returned by this function if it evaluates to true. + + :param is_true: An optional predicate that will terminate and + return when it evaluates to False. It should be a + function that will be passed clock and an end time. The + default predicate will terminate a wait when the clock + elapses the timeout. + + :param message: An optional message to include in the + exception's message if this function times out. + + """ + + rv = None + last_exc = None + until = is_true or until_pred + start = self.clock.now + + while not until(self.clock, self.end): + try: + next = self.clock.now + self.interval + rv = condition(self.marionette) + except (KeyboardInterrupt, SystemExit): + raise + except self.exceptions: + last_exc = sys.exc_info() + + # Re-adjust the interval depending on how long the callback + # took to evaluate the condition + interval_new = max(next - self.clock.now, 0) + + if not rv: + self.clock.sleep(interval_new) + continue + + if rv is not None: + return rv + + self.clock.sleep(interval_new) + + if message: + message = " with message: {}".format(message) + + raise errors.TimeoutException( + # pylint: disable=W1633 + "Timed out after {0:.1f} seconds{1}".format( + float(round((self.clock.now - start), 1)), message if message else "" + ), + cause=last_exc, + ) + + +def until_pred(clock, end): + return clock.now >= end + + +class SystemClock(object): + def __init__(self): + self._time = time + + def sleep(self, duration): + self._time.sleep(duration) + + @property + def now(self): + return self._time.time() diff --git a/testing/marionette/client/marionette_driver/webauthn.py b/testing/marionette/client/marionette_driver/webauthn.py new file mode 100644 index 0000000000..4970acbe5a --- /dev/null +++ b/testing/marionette/client/marionette_driver/webauthn.py @@ -0,0 +1,63 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + + +__all__ = ["WebAuthn"] + + +class WebAuthn(object): + def __init__(self, marionette): + self.marionette = marionette + + def add_virtual_authenticator(self, config): + body = { + "protocol": config["protocol"], + "transport": config["transport"], + "hasResidentKey": config.get("hasResidentKey", False), + "hasUserVerification": config.get("hasUserVerification", False), + "isUserConsenting": config.get("isUserConsenting", True), + "isUserVerified": config.get("isUserVerified", False), + } + return self.marionette._send_message( + "WebAuthn:AddVirtualAuthenticator", body, key="value" + ) + + def remove_virtual_authenticator(self, authenticator_id): + body = {"authenticatorId": authenticator_id} + return self.marionette._send_message( + "WebAuthn:RemoveVirtualAuthenticator", body + ) + + def add_credential(self, authenticator_id, credential): + body = { + "authenticatorId": authenticator_id, + "credentialId": credential["credentialId"], + "isResidentCredential": credential["isResidentCredential"], + "rpId": credential["rpId"], + "privateKey": credential["privateKey"], + "userHandle": credential.get("userHandle"), + "signCount": credential.get("signCount", 0), + } + return self.marionette._send_message("WebAuthn:AddCredential", body) + + def get_credentials(self, authenticator_id): + body = {"authenticatorId": authenticator_id} + return self.marionette._send_message( + "WebAuthn:GetCredentials", body, key="value" + ) + + def remove_credential(self, authenticator_id, credential_id): + body = {"authenticatorId": authenticator_id, "credentialId": credential_id} + return self.marionette._send_message("WebAuthn:RemoveCredential", body) + + def remove_all_credentials(self, authenticator_id): + body = {"authenticatorId": authenticator_id} + return self.marionette._send_message("WebAuthn:RemoveAllCredentials", body) + + def set_user_verified(self, authenticator_id, uv): + body = { + "authenticatorId": authenticator_id, + "isUserVerified": uv["isUserVerified"], + } + return self.marionette._send_message("WebAuthn:SetUserVerified", body) diff --git a/testing/marionette/client/requirements.txt b/testing/marionette/client/requirements.txt new file mode 100644 index 0000000000..220531c4f5 --- /dev/null +++ b/testing/marionette/client/requirements.txt @@ -0,0 +1,3 @@ +mozrunner >= 7.4.0 +mozversion >= 2.1.0 +six diff --git a/testing/marionette/client/setup.py b/testing/marionette/client/setup.py new file mode 100644 index 0000000000..676266c704 --- /dev/null +++ b/testing/marionette/client/setup.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/. + +import os +import re + +from setuptools import find_packages, setup + +THIS_DIR = os.path.dirname(os.path.realpath(__name__)) + + +def read(*parts): + with open(os.path.join(THIS_DIR, *parts)) as f: + return f.read() + + +def get_version(): + return re.findall( + '__version__ = "([\d\.]+)"', read("marionette_driver", "__init__.py"), re.M + )[0] + + +setup( + name="marionette_driver", + version=get_version(), + description="Marionette Driver", + long_description="""Note marionette_driver is no longer supported. + +For more information see https://firefox-source-docs.mozilla.org/python/marionette_driver.html""", + # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + classifiers=[ + "Development Status :: 7 - Inactive", + "Intended Audience :: Developers", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Topic :: Software Development :: Quality Assurance", + "Topic :: Software Development :: Testing", + "Topic :: Utilities", + "Programming Language :: Python", + "Programming Language :: Python :: 2.7", + ], + keywords="mozilla", + author="Auto-tools", + author_email="dev-webdriver@mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Marionette", + license="MPL", + packages=find_packages(), + include_package_data=True, + zip_safe=False, + install_requires=read("requirements.txt").splitlines(), +) diff --git a/testing/marionette/harness/MANIFEST.in b/testing/marionette/harness/MANIFEST.in new file mode 100644 index 0000000000..ce2d97cd30 --- /dev/null +++ b/testing/marionette/harness/MANIFEST.in @@ -0,0 +1,4 @@ +exclude MANIFEST.in +include requirements.txt +recursive-include marionette_harness/certificates * +recursive-include marionette_harness/www * diff --git a/testing/marionette/harness/README.rst b/testing/marionette/harness/README.rst new file mode 100644 index 0000000000..3f8865603e --- /dev/null +++ b/testing/marionette/harness/README.rst @@ -0,0 +1,30 @@ +marionette-harness +================== + +Marionette is an automation driver for Mozilla's Gecko engine. It can remotely +control either the UI or the internal JavaScript of a Gecko platform, such as +Firefox. It can control both the chrome (i.e. menus and functions) or the +content (the webpage loaded inside the browsing context), giving a high level +of control and ability to replicate user actions. In addition to performing +actions on the browser, Marionette can also read the properties and attributes +of the DOM. + +The marionette_harness package contains the test runner for Marionette, and +allows you to run automated tests written in Python for Gecko based +applications. Therefore it offers the necessary testcase classes, which are +based on the unittest framework. + +For more information and the repository please checkout: + +- home and docs: https://developer.mozilla.org/en-US/docs/Mozilla/QA/Marionette + + +Example +------- + +The following command will run the tests as specified via a manifest file, or +test path, or test folder in Firefox: + + marionette --binary %path_to_firefox% [manifest_file | test_file | test_folder] + +To get an overview about all possible option run `marionette --help`. diff --git a/testing/marionette/harness/marionette_harness/__init__.py b/testing/marionette/harness/marionette_harness/__init__.py new file mode 100644 index 0000000000..25e18ef56f --- /dev/null +++ b/testing/marionette/harness/marionette_harness/__init__.py @@ -0,0 +1,32 @@ +# 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__ = "5.0.2" + +from .marionette_test import ( + CommonTestCase, + MarionetteTestCase, + SkipTest, + expectedFailure, + parameterized, + run_if_manage_instance, + skip, + skip_if_chrome, + skip_if_desktop, + skip_unless_browser_pref, + skip_unless_protocol, + unexpectedSuccess, +) +from .runner import ( + BaseMarionetteArguments, + BaseMarionetteTestRunner, + Marionette, + MarionetteTest, + MarionetteTestResult, + MarionetteTextTestRunner, + TestManifest, + TestResult, + TestResultCollection, + WindowManagerMixin, +) diff --git a/testing/marionette/harness/marionette_harness/certificates/test.cert b/testing/marionette/harness/marionette_harness/certificates/test.cert new file mode 100644 index 0000000000..3fd1cba2b7 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/certificates/test.cert @@ -0,0 +1,86 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 2 (0x2) + Signature Algorithm: sha256WithRSAEncryption + Issuer: CN=web-platform-tests + Validity + Not Before: Dec 22 12:09:16 2014 GMT + Not After : Dec 21 12:09:16 2024 GMT + Subject: CN=web-platform.test + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:b3:84:d6:8b:01:59:18:85:d1:dc:32:df:38:f7: + 90:85:1b:3e:a5:5e:81:3e:2f:fc:3a:5f:7f:77:ef: + 23:bb:3a:88:27:0f:be:25:46:cd:63:7d:cb:95:d8: + a5:50:10:d2:a2:d2:b7:97:d1:0d:6c:fb:f9:05:e8: + 6f:a8:4b:bd:95:67:9e:7b:94:58:a9:6d:93:fd:e0: + 12:c5:cd:b4:8a:64:52:31:5f:0e:e3:89:84:71:da: + 98:dd:4b:ec:02:25:a5:7d:35:fe:63:da:b3:ac:ec: + a5:46:0f:0d:64:23:5c:6d:f3:ec:cc:28:63:23:c0: + 4b:9a:ec:8f:c1:ee:b1:a2:3e:72:4d:70:b5:09:c1: + eb:b4:10:55:3c:8b:ea:1b:94:7e:4b:74:e6:f4:9f: + 4f:a6:45:30:b5:f0:b8:b4:d1:59:50:65:0a:86:53: + ea:4c:9f:9e:f4:58:6c:31:f5:17:3a:6f:57:8b:cb: + 5f:f0:28:0b:45:92:8d:30:20:49:ff:52:e6:2c:cb: + 18:9a:d7:e6:ee:3e:4f:34:35:15:13:c5:02:da:c5: + 5f:be:fb:5b:ce:8d:bf:b5:35:76:3c:7c:e6:9c:3b: + 26:87:4d:8d:80:e6:16:c6:27:f2:50:49:b6:72:74: + 43:49:49:44:38:bb:78:43:23:ee:16:3e:d9:62:e6: + a5:d7 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + X509v3 Subject Key Identifier: + 2D:98:A3:99:39:1C:FE:E9:9A:6D:17:94:D2:3A:96:EE:C8:9E:04:22 + X509v3 Authority Key Identifier: + keyid:6A:AB:53:64:92:36:87:23:34:B3:1D:6F:85:4B:F5:DF:5A:5C:74:8F + + X509v3 Key Usage: + Digital Signature, Non Repudiation, Key Encipherment + X509v3 Extended Key Usage: + TLS Web Server Authentication + X509v3 Subject Alternative Name: + DNS:web-platform.test, DNS:www.web-platform.test, DNS:xn--n8j6ds53lwwkrqhv28a.web-platform.test, DNS:xn--lve-6lad.web-platform.test, DNS:www2.web-platform.test, DNS:www1.web-platform.test + Signature Algorithm: sha256WithRSAEncryption + 33:db:f7:f0:f6:92:16:4f:2d:42:bc:b8:aa:e6:ab:5e:f9:b9: + b0:48:ae:b5:8d:cc:02:7b:e9:6f:4e:75:f7:17:a0:5e:7b:87: + 06:49:48:83:c5:bb:ca:95:07:37:0e:5d:e3:97:de:9e:0c:a4: + 82:30:11:81:49:5d:50:29:72:92:a5:ca:17:b1:7c:f1:32:11: + 17:57:e6:59:c1:ac:e3:3b:26:d2:94:97:50:6a:b9:54:88:84: + 9b:6f:b1:06:f5:80:04:22:10:14:b1:f5:97:25:fc:66:d6:69: + a3:36:08:85:23:ff:8e:3c:2b:e0:6d:e7:61:f1:00:8f:61:3d: + b0:87:ad:72:21:f6:f0:cc:4f:c9:20:bf:83:11:0f:21:f4:b8: + c0:dd:9c:51:d7:bb:27:32:ec:ab:a4:62:14:28:32:da:f2:87: + 80:68:9c:ea:ac:eb:f5:7f:f5:de:f4:c0:39:91:c8:76:a4:ee: + d0:a8:50:db:c1:4b:f9:c4:3d:d9:e8:8e:b6:3f:c0:96:79:12: + d8:fa:4d:0a:b3:36:76:aa:4e:b2:82:2f:a2:d4:0d:db:fd:64: + 77:6f:6e:e9:94:7f:0f:c8:3a:3c:96:3d:cd:4d:6c:ba:66:95: + f7:b4:9d:a4:94:9f:97:b3:9a:0d:dc:18:8c:11:0b:56:65:8e: + 46:4c:e6:5e +-----BEGIN CERTIFICATE----- +MIID2jCCAsKgAwIBAgIBAjANBgkqhkiG9w0BAQsFADAdMRswGQYDVQQDDBJ3ZWIt +cGxhdGZvcm0tdGVzdHMwHhcNMTQxMjIyMTIwOTE2WhcNMjQxMjIxMTIwOTE2WjAc +MRowGAYDVQQDExF3ZWItcGxhdGZvcm0udGVzdDCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBALOE1osBWRiF0dwy3zj3kIUbPqVegT4v/Dpff3fvI7s6iCcP +viVGzWN9y5XYpVAQ0qLSt5fRDWz7+QXob6hLvZVnnnuUWKltk/3gEsXNtIpkUjFf +DuOJhHHamN1L7AIlpX01/mPas6zspUYPDWQjXG3z7MwoYyPAS5rsj8HusaI+ck1w +tQnB67QQVTyL6huUfkt05vSfT6ZFMLXwuLTRWVBlCoZT6kyfnvRYbDH1FzpvV4vL +X/AoC0WSjTAgSf9S5izLGJrX5u4+TzQ1FRPFAtrFX777W86Nv7U1djx85pw7JodN +jYDmFsYn8lBJtnJ0Q0lJRDi7eEMj7hY+2WLmpdcCAwEAAaOCASQwggEgMAkGA1Ud +EwQCMAAwHQYDVR0OBBYEFC2Yo5k5HP7pmm0XlNI6lu7IngQiMB8GA1UdIwQYMBaA +FGqrU2SSNocjNLMdb4VL9d9aXHSPMAsGA1UdDwQEAwIF4DATBgNVHSUEDDAKBggr +BgEFBQcDATCBsAYDVR0RBIGoMIGlghF3ZWItcGxhdGZvcm0udGVzdIIVd3d3Lndl +Yi1wbGF0Zm9ybS50ZXN0gil4bi0tbjhqNmRzNTNsd3drcnFodjI4YS53ZWItcGxh +dGZvcm0udGVzdIIeeG4tLWx2ZS02bGFkLndlYi1wbGF0Zm9ybS50ZXN0ghZ3d3cy +LndlYi1wbGF0Zm9ybS50ZXN0ghZ3d3cxLndlYi1wbGF0Zm9ybS50ZXN0MA0GCSqG +SIb3DQEBCwUAA4IBAQAz2/fw9pIWTy1CvLiq5qte+bmwSK61jcwCe+lvTnX3F6Be +e4cGSUiDxbvKlQc3Dl3jl96eDKSCMBGBSV1QKXKSpcoXsXzxMhEXV+ZZwazjOybS +lJdQarlUiISbb7EG9YAEIhAUsfWXJfxm1mmjNgiFI/+OPCvgbedh8QCPYT2wh61y +IfbwzE/JIL+DEQ8h9LjA3ZxR17snMuyrpGIUKDLa8oeAaJzqrOv1f/Xe9MA5kch2 +pO7QqFDbwUv5xD3Z6I62P8CWeRLY+k0KszZ2qk6ygi+i1A3b/WR3b27plH8PyDo8 +lj3NTWy6ZpX3tJ2klJ+Xs5oN3BiMEQtWZY5GTOZe +-----END CERTIFICATE-----
\ No newline at end of file diff --git a/testing/marionette/harness/marionette_harness/certificates/test.key b/testing/marionette/harness/marionette_harness/certificates/test.key new file mode 100644 index 0000000000..194a49ec42 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/certificates/test.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCzhNaLAVkYhdHc +Mt8495CFGz6lXoE+L/w6X3937yO7OognD74lRs1jfcuV2KVQENKi0reX0Q1s+/kF +6G+oS72VZ557lFipbZP94BLFzbSKZFIxXw7jiYRx2pjdS+wCJaV9Nf5j2rOs7KVG +Dw1kI1xt8+zMKGMjwEua7I/B7rGiPnJNcLUJweu0EFU8i+oblH5LdOb0n0+mRTC1 +8Li00VlQZQqGU+pMn570WGwx9Rc6b1eLy1/wKAtFko0wIEn/UuYsyxia1+buPk80 +NRUTxQLaxV+++1vOjb+1NXY8fOacOyaHTY2A5hbGJ/JQSbZydENJSUQ4u3hDI+4W +Ptli5qXXAgMBAAECggEBAIcwDQSnIjo2ZECHytQykpG6X6XXEksLhc1Lp0lhPC49 +uNR5pX6a4AcBb3PLr0opMQZO2tUoKA0ff3t0e8loKD+/xXhY0Z/dlioEOP7elwv0 +2nS1mhe9spCuxpk4GGXRhdtR8t2tj8s0do3YvgPgITXoEDX6YBZHNGhZpzSrFPgQ +/c3eGCVmzWYuLFfdj5OPQ9bwTaY4JSvDLZT0/WTgiica7VySwfz3HP1fFqNykTiK +ACQREvtxfk5Ym2nT6oni7CM2zOEJL9SXicXI5HO4bERH0ZYh//F3g6mwGiFXUJPd +NKgaTM1oT9kRGkUaEYsRWrddwR8d5mXLvBuTJbgIsSECgYEA1+2uJSYRW1OqbhYP +ms59YQHSs3VjpJpnCV2zNa2Wixs57KS2cOH7B6KrQCogJFLtgCDVLtyoErfVkD7E +FivTgYr1pVCRppJddQzXik31uOINOBVffr7/09g3GcRN+ubHPZPq3K+dD6gHa3Aj +0nH1EjEEV0QpSTQFn87OF2mc9wcCgYEA1NVqMbbzd+9Xft5FXuSbX6E+S02dOGat +SgpnkTM80rjqa6eHdQzqk3JqyteHPgdi1vdYRlSPOj/X+6tySY0Ej9sRnYOfddA2 +kpiDiVkmiqVolyJPY69Utj+E3TzJ1vhCQuYknJmB7zP9tDcTxMeq0l/NaWvGshEK +yC4UTQog1rECgYASOFILfGzWgfbNlzr12xqlRtwanHst9oFfPvLSQrWDQ2bd2wAy +Aj+GY2mD3oobxouX1i1m6OOdwLlalJFDNauBMNKNgoDnx03vhIfjebSURy7KXrNS +JJe9rm7n07KoyzRgs8yLlp3wJkOKA0pihY8iW9R78JpzPNqEo5SsURMXnQKBgBlV +gfuC9H4tPjP6zzUZbyk1701VYsaI6k2q6WMOP0ox+q1v1p7nN7DvaKjWeOG4TVqb +PKW6gQYE/XeWk9cPcyCQigs+1KdYbnaKsvWRaBYO1GFREzQhdarv6qfPCZOOH40J +Cgid+Sp4/NULzU2aGspJ3xCSZKdjge4MFhyJfRkxAoGBAJlwqY4nue0MBLGNpqcs +WwDtSasHvegKAcxGBKL5oWPbLBk7hk+hdqc8f6YqCkCNqv/ooBspL15ESItL+6yT +zt0YkK4oH9tmLDb+rvqZ7ZdXbWSwKITMoCyyHUtT6OKt/RtA0Vdy9LPnP27oSO/C +dk8Qf7KgKZLWo0ZNkvw38tEC +-----END PRIVATE KEY-----
\ No newline at end of file diff --git a/testing/marionette/harness/marionette_harness/marionette_test/__init__.py b/testing/marionette/harness/marionette_harness/marionette_test/__init__.py new file mode 100644 index 0000000000..436a282f26 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/marionette_test/__init__.py @@ -0,0 +1,24 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +__version__ = "3.1.0" + +from unittest.case import SkipTest, skip + +from .decorators import ( + parameterized, + run_if_manage_instance, + skip_if_chrome, + skip_if_desktop, + skip_unless_browser_pref, + skip_unless_protocol, + with_parameters, +) +from .testcases import ( + CommonTestCase, + MarionetteTestCase, + MetaParameterized, + expectedFailure, + unexpectedSuccess, +) diff --git a/testing/marionette/harness/marionette_harness/marionette_test/decorators.py b/testing/marionette/harness/marionette_harness/marionette_test/decorators.py new file mode 100644 index 0000000000..cc3aa091d8 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/marionette_test/decorators.py @@ -0,0 +1,194 @@ +# 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 functools +import types +from unittest.case import SkipTest + + +def parameterized(func_suffix, *args, **kwargs): + r"""Decorator which generates methods given a base method and some data. + + **func_suffix** is used as a suffix for the new created method and must be + unique given a base method. if **func_suffix** countains characters that + are not allowed in normal python function name, these characters will be + replaced with "_". + + This decorator can be used more than once on a single base method. The class + must have a metaclass of :class:`MetaParameterized`. + + Example:: + + # This example will generate two methods: + # + # - MyTestCase.test_it_1 + # - MyTestCase.test_it_2 + # + class MyTestCase(MarionetteTestCase): + @parameterized("1", 5, named='name') + @parameterized("2", 6, named='name2') + def test_it(self, value, named=None): + print value, named + + :param func_suffix: will be used as a suffix for the new method + :param \*args: arguments to pass to the new method + :param \*\*kwargs: named arguments to pass to the new method + """ + + def wrapped(func): + if not hasattr(func, "metaparameters"): + func.metaparameters = [] + func.metaparameters.append((func_suffix, args, kwargs)) + return func + + return wrapped + + +def run_if_manage_instance(reason): + """Decorator which runs a test if Marionette manages the application instance.""" + + def decorator(test_item): + if not isinstance(test_item, types.FunctionType): + raise Exception("Decorator only supported for functions") + + @functools.wraps(test_item) + def skip_wrapper(self, *args, **kwargs): + if self.marionette.instance is None: + raise SkipTest(reason) + return test_item(self, *args, **kwargs) + + return skip_wrapper + + return decorator + + +def skip_if_chrome(reason): + """Decorator which skips a test if chrome context is active.""" + + def decorator(test_item): + if not isinstance(test_item, types.FunctionType): + raise Exception("Decorator only supported for functions") + + @functools.wraps(test_item) + def skip_wrapper(self, *args, **kwargs): + if self.marionette._send_message("getContext", key="value") == "chrome": + raise SkipTest(reason) + return test_item(self, *args, **kwargs) + + return skip_wrapper + + return decorator + + +def skip_if_desktop(reason): + """Decorator which skips a test if run on desktop.""" + + def decorator(test_item): + if not isinstance(test_item, types.FunctionType): + raise Exception("Decorator only supported for functions") + + @functools.wraps(test_item) + def skip_wrapper(self, *args, **kwargs): + if self.marionette.session_capabilities.get("browserName") == "firefox": + raise SkipTest(reason) + return test_item(self, *args, **kwargs) + + return skip_wrapper + + return decorator + + +def skip_unless_browser_pref(reason, pref, predicate=bool): + """Decorator which skips a test based on the value of a browser preference. + + :param reason: Message describing why the test need to be skipped. + :param pref: the preference name + :param predicate: a function that should return false to skip the test. + The function takes one parameter, the preference value. + Defaults to the python built-in bool function. + + Note that the preference must exist, else a failure is raised. + + Example: :: + + class TestSomething(MarionetteTestCase): + @skip_unless_browser_pref("Sessionstore needs to be enabled for crashes", + "browser.sessionstore.resume_from_crash", + lambda value: value is True, + ) + def test_foo(self): + pass # test implementation here + + """ + + def decorator(test_item): + if not isinstance(test_item, types.FunctionType): + raise Exception("Decorator only supported for functions") + if not callable(predicate): + raise ValueError("predicate must be callable") + + @functools.wraps(test_item) + def skip_wrapper(self, *args, **kwargs): + value = self.marionette.get_pref(pref) + if value is None: + self.fail("No such browser preference: {0!r}".format(pref)) + if not predicate(value): + raise SkipTest(reason) + return test_item(self, *args, **kwargs) + + return skip_wrapper + + return decorator + + +def skip_unless_protocol(reason, predicate): + """Decorator which skips a test if the predicate does not match the current protocol level.""" + + def decorator(test_item): + if not isinstance(test_item, types.FunctionType): + raise Exception("Decorator only supported for functions") + if not callable(predicate): + raise ValueError("predicate must be callable") + + @functools.wraps(test_item) + def skip_wrapper(self, *args, **kwargs): + level = self.marionette.client.protocol + if not predicate(level): + raise SkipTest(reason) + return test_item(self, *args, **kwargs) + + return skip_wrapper + + return decorator + + +def with_parameters(parameters): + """Decorator which generates methods given a base method and some data. + + Acts like :func:`parameterized`, but define all methods in one call. + + Example:: + + # This example will generate two methods: + # + # - MyTestCase.test_it_1 + # - MyTestCase.test_it_2 + # + + DATA = [("1", [5], {'named':'name'}), ("2", [6], {'named':'name2'})] + + class MyTestCase(MarionetteTestCase): + @with_parameters(DATA) + def test_it(self, value, named=None): + print value, named + + :param parameters: list of tuples (**func_suffix**, **args**, **kwargs**) + defining parameters like in :func:`todo`. + """ + + def wrapped(func): + func.metaparameters = parameters + return func + + return wrapped diff --git a/testing/marionette/harness/marionette_harness/marionette_test/testcases.py b/testing/marionette/harness/marionette_harness/marionette_test/testcases.py new file mode 100644 index 0000000000..009e701f2d --- /dev/null +++ b/testing/marionette/harness/marionette_harness/marionette_test/testcases.py @@ -0,0 +1,420 @@ +# 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 +import sys +import time +import unittest +import warnings +import weakref +from unittest.case import SkipTest + +import six +from marionette_driver.errors import TimeoutException, UnresponsiveInstanceException +from mozfile import load_source +from mozlog import get_default_logger + + +# With Python 3 both expectedFailure and unexpectedSuccess are +# available in unittest/case.py but won't work here because both +# do not inherit from BaseException. And that's currently needed +# in our custom test status handling in `run()`. +class expectedFailure(Exception): + """ + Raise this when a test is expected to fail. + + This is an implementation detail. + """ + + def __init__(self, exc_info): + super(expectedFailure, self).__init__() + self.exc_info = exc_info + + +class unexpectedSuccess(Exception): + """ + The test was supposed to fail, but it didn't! + """ + + pass + + +def _wraps_parameterized(func, func_suffix, args, kwargs): + """Internal: Decorator used in class MetaParameterized.""" + + def wrapper(self): + return func(self, *args, **kwargs) + + wrapper.__name__ = func.__name__ + "_" + str(func_suffix) + wrapper.__doc__ = "[{0}] {1}".format(func_suffix, func.__doc__) + return wrapper + + +class MetaParameterized(type): + """ + A metaclass that allow a class to use decorators. + + It can be used like :func:`parameterized` + or :func:`with_parameters` to generate new methods. + """ + + RE_ESCAPE_BAD_CHARS = re.compile(r"[\.\(\) -/]") + + def __new__(cls, name, bases, attrs): + for k, v in list(attrs.items()): + if callable(v) and hasattr(v, "metaparameters"): + for func_suffix, args, kwargs in v.metaparameters: + func_suffix = cls.RE_ESCAPE_BAD_CHARS.sub("_", func_suffix) + wrapper = _wraps_parameterized(v, func_suffix, args, kwargs) + if wrapper.__name__ in attrs: + raise KeyError( + "{0} is already a defined method on {1}".format( + wrapper.__name__, name + ) + ) + attrs[wrapper.__name__] = wrapper + del attrs[k] + + return type.__new__(cls, name, bases, attrs) + + +@six.add_metaclass(MetaParameterized) +class CommonTestCase(unittest.TestCase): + match_re = None + failureException = AssertionError + pydebugger = None + + def __init__(self, methodName, marionette_weakref, fixtures, **kwargs): + super(CommonTestCase, self).__init__(methodName) + self.methodName = methodName + + self._marionette_weakref = marionette_weakref + self.fixtures = fixtures + + self.duration = 0 + self.start_time = 0 + self.expected = kwargs.pop("expected", "pass") + self.logger = get_default_logger() + + def _enter_pm(self): + if self.pydebugger: + self.pydebugger.post_mortem(sys.exc_info()[2]) + + def _addSkip(self, result, reason): + addSkip = getattr(result, "addSkip", None) + if addSkip is not None: + addSkip(self, reason) + else: + warnings.warn( + "TestResult has no addSkip method, skips not reported", + RuntimeWarning, + 2, + ) + result.addSuccess(self) + + def assertRaisesRegxp( + self, expected_exception, expected_regexp, callable_obj=None, *args, **kwargs + ): + return six.assertRaisesRegex( + self, + expected_exception, + expected_regexp, + callable_obj=None, + *args, + **kwargs + ) + + def run(self, result=None): + # Bug 967566 suggests refactoring run, which would hopefully + # mean getting rid of this inner function, which only sits + # here to reduce code duplication: + def expected_failure(result, exc_info): + addExpectedFailure = getattr(result, "addExpectedFailure", None) + if addExpectedFailure is not None: + addExpectedFailure(self, exc_info) + else: + warnings.warn( + "TestResult has no addExpectedFailure method, " + "reporting as passes", + RuntimeWarning, + ) + result.addSuccess(self) + + self.start_time = time.time() + orig_result = result + if result is None: + result = self.defaultTestResult() + startTestRun = getattr(result, "startTestRun", None) + if startTestRun is not None: + startTestRun() + + result.startTest(self) + + testMethod = getattr(self, self._testMethodName) + if getattr(self.__class__, "__unittest_skip__", False) or getattr( + testMethod, "__unittest_skip__", False + ): + # If the class or method was skipped. + try: + skip_why = getattr( + self.__class__, "__unittest_skip_why__", "" + ) or getattr(testMethod, "__unittest_skip_why__", "") + self._addSkip(result, skip_why) + finally: + result.stopTest(self) + self.stop_time = time.time() + return + try: + success = False + try: + if self.expected == "fail": + try: + self.setUp() + except Exception: + raise expectedFailure(sys.exc_info()) + else: + self.setUp() + except SkipTest as e: + self._addSkip(result, str(e)) + except (KeyboardInterrupt, UnresponsiveInstanceException): + raise + except expectedFailure as e: + expected_failure(result, e.exc_info) + except Exception: + self._enter_pm() + result.addError(self, sys.exc_info()) + else: + try: + if self.expected == "fail": + try: + testMethod() + except Exception: + raise expectedFailure(sys.exc_info()) + raise unexpectedSuccess + else: + testMethod() + except self.failureException: + self._enter_pm() + result.addFailure(self, sys.exc_info()) + except (KeyboardInterrupt, UnresponsiveInstanceException): + raise + except expectedFailure as e: + expected_failure(result, e.exc_info) + except unexpectedSuccess: + addUnexpectedSuccess = getattr(result, "addUnexpectedSuccess", None) + if addUnexpectedSuccess is not None: + addUnexpectedSuccess(self) + else: + warnings.warn( + "TestResult has no addUnexpectedSuccess method, " + "reporting as failures", + RuntimeWarning, + ) + result.addFailure(self, sys.exc_info()) + except SkipTest as e: + self._addSkip(result, str(e)) + except Exception: + self._enter_pm() + result.addError(self, sys.exc_info()) + else: + success = True + try: + if self.expected == "fail": + try: + self.tearDown() + except Exception: + raise expectedFailure(sys.exc_info()) + else: + self.tearDown() + except (KeyboardInterrupt, UnresponsiveInstanceException): + raise + except expectedFailure as e: + expected_failure(result, e.exc_info) + except Exception: + self._enter_pm() + result.addError(self, sys.exc_info()) + success = False + # Here we could handle doCleanups() instead of calling cleanTest directly + self.cleanTest() + + if success: + result.addSuccess(self) + + finally: + result.stopTest(self) + if orig_result is None: + stopTestRun = getattr(result, "stopTestRun", None) + if stopTestRun is not None: + stopTestRun() + + @classmethod + def match(cls, filename): + """Determine if the specified filename should be handled by this test class. + + This is done by looking for a match for the filename using cls.match_re. + """ + if not cls.match_re: + return False + m = cls.match_re.match(filename) + return m is not None + + @classmethod + def add_tests_to_suite( + cls, + mod_name, + filepath, + suite, + testloader, + marionette, + fixtures, + testvars, + **kwargs + ): + """Add all the tests in the specified file to the specified suite.""" + raise NotImplementedError + + @property + def test_name(self): + rel_path = None + if os.path.exists(self.filepath): + rel_path = self._fix_test_path(self.filepath) + + return "{0} {1}.{2}".format( + rel_path, self.__class__.__name__, self._testMethodName + ) + + def id(self): + # TBPL starring requires that the "test name" field of a failure message + # not differ over time. The test name to be used is passed to + # mozlog via the test id, so this is overriden to maintain + # consistency. + return self.test_name + + def setUp(self): + # Convert the marionette weakref to an object, just for the + # duration of the test; this is deleted in tearDown() to prevent + # a persistent circular reference which in turn would prevent + # proper garbage collection. + self.start_time = time.time() + self.marionette = self._marionette_weakref() + if self.marionette.session is None: + self.marionette.start_session() + self.marionette.timeout.reset() + + super(CommonTestCase, self).setUp() + + def cleanTest(self): + self._delete_session() + + def _delete_session(self): + if hasattr(self, "start_time"): + self.duration = time.time() - self.start_time + if self.marionette.session is not None: + try: + self.marionette.delete_session() + except IOError: + # Gecko has crashed? + pass + self.marionette = None + + def _fix_test_path(self, path): + """Normalize a logged test path from the test package.""" + test_path_prefixes = [ + "tests{}".format(os.path.sep), + ] + + path = os.path.relpath(path) + for prefix in test_path_prefixes: + if path.startswith(prefix): + path = path[len(prefix) :] + break + path = path.replace("\\", "/") + + return path + + +class MarionetteTestCase(CommonTestCase): + match_re = re.compile(r"test_(.*)\.py$") + + def __init__( + self, marionette_weakref, fixtures, methodName="runTest", filepath="", **kwargs + ): + self.filepath = filepath + self.testvars = kwargs.pop("testvars", None) + + super(MarionetteTestCase, self).__init__( + methodName, + marionette_weakref=marionette_weakref, + fixtures=fixtures, + **kwargs + ) + + @classmethod + def add_tests_to_suite( + cls, + mod_name, + filepath, + suite, + testloader, + marionette, + fixtures, + testvars, + **kwargs + ): + # since load_source caches modules, if a module is loaded with the same + # name as another one the module would just be reloaded. + # + # We may end up by finding too many test in a module then since reload() + # only update the module dict (so old keys are still there!) see + # https://docs.python.org/2/library/functions.html#reload + # + # we get rid of that by removing the module from sys.modules, so we + # ensure that it will be fully loaded by the imp.load_source call. + + if mod_name in sys.modules: + del sys.modules[mod_name] + + test_mod = load_source(mod_name, filepath) + + for name in dir(test_mod): + obj = getattr(test_mod, name) + if isinstance(obj, six.class_types) and issubclass(obj, unittest.TestCase): + testnames = testloader.getTestCaseNames(obj) + for testname in testnames: + suite.addTest( + obj( + weakref.ref(marionette), + fixtures, + methodName=testname, + filepath=filepath, + testvars=testvars, + **kwargs + ) + ) + + def setUp(self): + super(MarionetteTestCase, self).setUp() + self.marionette.test_name = self.test_name + + def tearDown(self): + # In the case no session is active (eg. the application was quit), start + # a new session for clean-up steps. + if not self.marionette.session: + self.marionette.start_session() + + self.marionette.test_name = None + + super(MarionetteTestCase, self).tearDown() + + def wait_for_condition(self, method, timeout=30): + timeout = float(timeout) + time.time() + while time.time() < timeout: + value = method(self.marionette) + if value: + return value + time.sleep(0.5) + else: + raise TimeoutException("wait_for_condition timed out") diff --git a/testing/marionette/harness/marionette_harness/runner/__init__.py b/testing/marionette/harness/marionette_harness/runner/__init__.py new file mode 100644 index 0000000000..2fdac637d3 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/runner/__init__.py @@ -0,0 +1,16 @@ +# 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 .base import ( + BaseMarionetteArguments, + BaseMarionetteTestRunner, + Marionette, + MarionetteTest, + MarionetteTestResult, + MarionetteTextTestRunner, + TestManifest, + TestResult, + TestResultCollection, +) +from .mixins import WindowManagerMixin diff --git a/testing/marionette/harness/marionette_harness/runner/base.py b/testing/marionette/harness/marionette_harness/runner/base.py new file mode 100644 index 0000000000..b5ddc2d788 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/runner/base.py @@ -0,0 +1,1265 @@ +# 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 os +import random +import re +import socket +import sys +import time +import traceback +import unittest +from argparse import ArgumentParser +from collections import defaultdict +from copy import deepcopy + +import mozinfo +import moznetwork +import mozprofile +import mozversion +import six +from manifestparser import TestManifest +from manifestparser.filters import tags +from marionette_driver.marionette import Marionette +from moztest.adapters.unit import StructuredTestResult, StructuredTestRunner +from moztest.results import TestResult, TestResultCollection, relevant_line +from six import MAXSIZE, reraise + +from . import serve + +here = os.path.abspath(os.path.dirname(__file__)) + + +def update_mozinfo(path=None): + """Walk up directories to find mozinfo.json and update the info.""" + path = path or here + dirs = set() + while path != os.path.expanduser("~"): + if path in dirs: + break + dirs.add(path) + path = os.path.split(path)[0] + + return mozinfo.find_and_update_from_json(*dirs) + + +class MarionetteTest(TestResult): + @property + def test_name(self): + if self.test_class is not None: + return "{0}.py {1}.{2}".format( + self.test_class.split(".")[0], self.test_class, self.name + ) + else: + return self.name + + +class MarionetteTestResult(StructuredTestResult, TestResultCollection): + resultClass = MarionetteTest + + def __init__(self, *args, **kwargs): + self.marionette = kwargs.pop("marionette") + TestResultCollection.__init__(self, "MarionetteTest") + self.passed = 0 + self.testsRun = 0 + self.result_modifiers = [] # used by mixins to modify the result + StructuredTestResult.__init__(self, *args, **kwargs) + + @property + def skipped(self): + return [t for t in self if t.result == "SKIPPED"] + + @skipped.setter + def skipped(self, value): + pass + + @property + def expectedFailures(self): + return [t for t in self if t.result == "KNOWN-FAIL"] + + @expectedFailures.setter + def expectedFailures(self, value): + pass + + @property + def unexpectedSuccesses(self): + return [t for t in self if t.result == "UNEXPECTED-PASS"] + + @unexpectedSuccesses.setter + def unexpectedSuccesses(self, value): + pass + + @property + def tests_passed(self): + return [t for t in self if t.result == "PASS"] + + @property + def errors(self): + return [t for t in self if t.result == "ERROR"] + + @errors.setter + def errors(self, value): + pass + + @property + def failures(self): + return [t for t in self if t.result == "UNEXPECTED-FAIL"] + + @failures.setter + def failures(self, value): + pass + + @property + def duration(self): + if self.stop_time: + return self.stop_time - self.start_time + else: + return 0 + + def add_test_result( + self, + test, + result_expected="PASS", + result_actual="PASS", + output="", + context=None, + **kwargs + ): + def get_class(test): + return test.__class__.__module__ + "." + test.__class__.__name__ + + name = str(test).split()[0] + test_class = get_class(test) + if hasattr(test, "jsFile"): + name = os.path.basename(test.jsFile) + test_class = None + + t = self.resultClass( + name=name, + test_class=test_class, + time_start=test.start_time, + result_expected=result_expected, + context=context, + **kwargs + ) + # call any registered result modifiers + for modifier in self.result_modifiers: + result_expected, result_actual, output, context = modifier( + t, result_expected, result_actual, output, context + ) + t.finish( + result_actual, + time_end=time.time() if test.start_time else 0, + reason=relevant_line(output), + output=output, + ) + self.append(t) + + def addError(self, test, err): + self.add_test_result( + test, output=self._exc_info_to_string(err, test), result_actual="ERROR" + ) + super(MarionetteTestResult, self).addError(test, err) + + def addFailure(self, test, err): + self.add_test_result( + test, + output=self._exc_info_to_string(err, test), + result_actual="UNEXPECTED-FAIL", + ) + super(MarionetteTestResult, self).addFailure(test, err) + + def addSuccess(self, test): + self.passed += 1 + self.add_test_result(test, result_actual="PASS") + super(MarionetteTestResult, self).addSuccess(test) + + def addExpectedFailure(self, test, err): + """Called when an expected failure/error occured.""" + self.add_test_result( + test, output=self._exc_info_to_string(err, test), result_actual="KNOWN-FAIL" + ) + super(MarionetteTestResult, self).addExpectedFailure(test, err) + + def addUnexpectedSuccess(self, test): + """Called when a test was expected to fail, but succeed.""" + self.add_test_result(test, result_actual="UNEXPECTED-PASS") + super(MarionetteTestResult, self).addUnexpectedSuccess(test) + + def addSkip(self, test, reason): + self.add_test_result(test, output=reason, result_actual="SKIPPED") + super(MarionetteTestResult, self).addSkip(test, reason) + + def getInfo(self, test): + return test.test_name + + def getDescription(self, test): + doc_first_line = test.shortDescription() + if self.descriptions and doc_first_line: + return "\n".join((str(test), doc_first_line)) + else: + desc = str(test) + return desc + + def printLogs(self, test): + for testcase in test._tests: + if hasattr(testcase, "loglines") and testcase.loglines: + # Don't dump loglines to the console if they only contain + # TEST-START and TEST-END. + skip_log = True + for line in testcase.loglines: + str_line = " ".join(line) + if "TEST-END" not in str_line and "TEST-START" not in str_line: + skip_log = False + break + if skip_log: + return + self.logger.info("START LOG:") + for line in testcase.loglines: + self.logger.info(" ".join(line).encode("ascii", "replace")) + self.logger.info("END LOG:") + + def stopTest(self, *args, **kwargs): + unittest._TextTestResult.stopTest(self, *args, **kwargs) + if self.marionette.check_for_crash(): + # this tells unittest.TestSuite not to continue running tests + self.shouldStop = True + test = next((a for a in args if isinstance(a, unittest.TestCase)), None) + if test: + self.addError(test, sys.exc_info()) + + +class MarionetteTextTestRunner(StructuredTestRunner): + resultclass = MarionetteTestResult + + def __init__(self, **kwargs): + self.marionette = kwargs.pop("marionette") + self.capabilities = kwargs.pop("capabilities") + + StructuredTestRunner.__init__(self, **kwargs) + + def _makeResult(self): + return self.resultclass( + self.stream, + self.descriptions, + self.verbosity, + marionette=self.marionette, + logger=self.logger, + result_callbacks=self.result_callbacks, + ) + + def run(self, test): + result = super(MarionetteTextTestRunner, self).run(test) + result.printLogs(test) + return result + + +class BaseMarionetteArguments(ArgumentParser): + def __init__(self, **kwargs): + ArgumentParser.__init__(self, **kwargs) + + def dir_path(path): + path = os.path.abspath(os.path.expanduser(path)) + if not os.access(path, os.F_OK): + os.makedirs(path) + return path + + self.argument_containers = [] + self.add_argument( + "tests", + nargs="*", + default=[], + help="Tests to run. " + "One or more paths to test files (Python or JS), " + "manifest files (.toml) or directories. " + "When a directory is specified, " + "all test files in the directory will be run.", + ) + self.add_argument( + "--binary", + help="path to gecko executable to launch before running the test", + ) + self.add_argument( + "--address", help="host:port of running Gecko instance to connect to" + ) + self.add_argument( + "--emulator", + action="store_true", + help="If no --address is given, then the harness will launch an " + "emulator. (See Remote options group.) " + "If --address is given, then the harness assumes you are " + "running an emulator already, and will launch gecko app " + "on that emulator.", + ) + self.add_argument( + "--app", help="application to use. see marionette_driver.geckoinstance" + ) + self.add_argument( + "--app-arg", + dest="app_args", + action="append", + default=[], + help="specify a command line argument to be passed onto the application", + ) + self.add_argument( + "--profile", + help="profile to use when launching the gecko process. If not passed, " + "then a profile will be constructed and used", + type=dir_path, + ) + self.add_argument( + "--setpref", + action="append", + metavar="PREF=VALUE", + dest="prefs_args", + help="set a browser preference; repeat for multiple preferences.", + ) + self.add_argument( + "--preferences", + action="append", + dest="prefs_files", + help="read preferences from a JSON or TOML file. For TOML, use " + "'file.toml:section' to specify a particular section.", + ) + self.add_argument( + "--addon", + action="append", + dest="addons", + help="addon to install; repeat for multiple addons.", + ) + self.add_argument( + "--repeat", type=int, help="number of times to repeat the test(s)" + ) + self.add_argument( + "--run-until-failure", + action="store_true", + help="Run tests repeatedly and stop on the first time a test fails. " + "Default cap is 30 runs, which can be overwritten " + "with the --repeat parameter.", + ) + self.add_argument( + "--testvars", + action="append", + help="path to a json file with any test data required", + ) + self.add_argument( + "--symbols-path", + help="absolute path to directory containing breakpad symbols, or the " + "url of a zip file containing symbols", + ) + self.add_argument( + "--socket-timeout", + type=float, + default=Marionette.DEFAULT_SOCKET_TIMEOUT, + help="Set the global timeout for marionette socket operations." + " Default: %(default)ss.", + ) + self.add_argument( + "--startup-timeout", + type=int, + default=Marionette.DEFAULT_STARTUP_TIMEOUT, + help="the max number of seconds to wait for a Marionette connection " + "after launching a binary. Default: %(default)ss.", + ) + self.add_argument( + "--shuffle", + action="store_true", + default=False, + help="run tests in a random order", + ) + self.add_argument( + "--shuffle-seed", + type=int, + default=random.randint(0, MAXSIZE), + help="Use given seed to shuffle tests", + ) + self.add_argument( + "--total-chunks", + type=int, + help="how many chunks to split the tests up into", + ) + self.add_argument("--this-chunk", type=int, help="which chunk to run") + self.add_argument( + "--server-root", + help="url to a webserver or path to a document root from which content " + "resources are served (default: {}).".format( + os.path.join(os.path.dirname(here), "www") + ), + ) + self.add_argument( + "--gecko-log", + help="Define the path to store log file. If the path is" + " a directory, the real log file will be created" + " given the format gecko-(timestamp).log. If it is" + " a file, if will be used directly. '-' may be passed" + " to write to stdout. Default: './gecko.log'", + ) + self.add_argument( + "--logger-name", + default="Marionette-based Tests", + help="Define the name to associate with the logger used", + ) + self.add_argument( + "--jsdebugger", + action="store_true", + default=False, + help="Enable the jsdebugger for marionette javascript.", + ) + self.add_argument( + "--pydebugger", + help="Enable python post-mortem debugger when a test fails." + " Pass in the debugger you want to use, eg pdb or ipdb.", + ) + self.add_argument( + "--disable-fission", + action="store_true", + dest="disable_fission", + default=False, + help="Disable Fission (site isolation) in Gecko.", + ) + self.add_argument( + "-z", + "--headless", + action="store_true", + dest="headless", + default=os.environ.get("MOZ_HEADLESS", False), + help="Run tests in headless mode.", + ) + self.add_argument( + "--tag", + action="append", + dest="test_tags", + default=None, + help="Filter out tests that don't have the given tag. Can be " + "used multiple times in which case the test must contain " + "at least one of the given tags.", + ) + self.add_argument( + "--workspace", + action="store", + default=None, + help="Path to directory for Marionette output. " + "(Default: .) (Default profile dest: TMP)", + type=dir_path, + ) + self.add_argument( + "-v", + "--verbose", + action="count", + help="Increase verbosity to include debug messages with -v, " + "and trace messages with -vv.", + ) + self.register_argument_container(RemoteMarionetteArguments()) + + def register_argument_container(self, container): + group = self.add_argument_group(container.name) + + for cli, kwargs in container.args: + group.add_argument(*cli, **kwargs) + + self.argument_containers.append(container) + + def parse_known_args(self, args=None, namespace=None): + args, remainder = ArgumentParser.parse_known_args(self, args, namespace) + for container in self.argument_containers: + if hasattr(container, "parse_args_handler"): + container.parse_args_handler(args) + return (args, remainder) + + def _get_preferences(self, prefs_files, prefs_args): + """Return user defined profile preferences as a dict.""" + # object that will hold the preferences + prefs = mozprofile.prefs.Preferences() + + # add preferences files + if prefs_files: + for prefs_file in prefs_files: + prefs.add_file(prefs_file) + + separator = "=" + cli_prefs = [] + if prefs_args: + misformatted = [] + for pref in prefs_args: + if separator not in pref: + misformatted.append(pref) + else: + cli_prefs.append(pref.split(separator, 1)) + if misformatted: + self._print_message( + "Warning: Ignoring preferences not in key{}value format: {}\n".format( + separator, ", ".join(misformatted) + ) + ) + # string preferences + prefs.add(cli_prefs, cast=True) + + return dict(prefs()) + + def verify_usage(self, args): + if not args.tests: + self.error( + "You must specify one or more test files, manifests, or directories." + ) + + missing_tests = [path for path in args.tests if not os.path.exists(path)] + if missing_tests: + self.error( + "Test file(s) not found: " + " ".join([path for path in missing_tests]) + ) + + if not args.address and not args.binary and not args.emulator: + self.error("You must specify --binary, or --address, or --emulator") + + if args.repeat is not None and args.repeat < 0: + self.error("The value of --repeat has to be equal or greater than 0.") + + if args.total_chunks is not None and args.this_chunk is None: + self.error("You must specify which chunk to run.") + + if args.this_chunk is not None and args.total_chunks is None: + self.error("You must specify how many chunks to split the tests into.") + + if args.total_chunks is not None: + if not 1 < args.total_chunks: + self.error("Total chunks must be greater than 1.") + if not 1 <= args.this_chunk <= args.total_chunks: + self.error( + "Chunk to run must be between 1 and {}.".format(args.total_chunks) + ) + + if args.jsdebugger: + args.app_args.append("-jsdebugger") + args.socket_timeout = None + + args.prefs = self._get_preferences(args.prefs_files, args.prefs_args) + + for container in self.argument_containers: + if hasattr(container, "verify_usage_handler"): + container.verify_usage_handler(args) + + return args + + +class RemoteMarionetteArguments(object): + name = "Remote (Emulator/Device)" + args = [ + [ + ["--emulator-binary"], + { + "help": "Path to emulator binary. By default mozrunner uses `which emulator`", + "dest": "emulator_bin", + }, + ], + [ + ["--adb"], + { + "help": "Path to the adb. By default mozrunner uses `which adb`", + "dest": "adb_path", + }, + ], + [ + ["--avd"], + { + "help": ( + "Name of an AVD available in your environment." + "See mozrunner.FennecEmulatorRunner" + ), + }, + ], + [ + ["--avd-home"], + { + "help": "Path to avd parent directory", + }, + ], + [ + ["--device"], + { + "help": ( + "Serial ID to connect to as seen in `adb devices`," + "e.g emulator-5444" + ), + "dest": "device_serial", + }, + ], + [ + ["--package"], + { + "help": "Name of Android package, e.g. org.mozilla.fennec", + "dest": "package_name", + }, + ], + ] + + +class Fixtures(object): + def where_is(self, uri, on="http"): + return serve.where_is(uri, on) + + +class BaseMarionetteTestRunner(object): + textrunnerclass = MarionetteTextTestRunner + driverclass = Marionette + + def __init__( + self, + address=None, + app=None, + app_args=None, + binary=None, + profile=None, + logger=None, + logdir=None, + repeat=None, + run_until_failure=None, + testvars=None, + symbols_path=None, + shuffle=False, + shuffle_seed=random.randint(0, MAXSIZE), + this_chunk=1, + total_chunks=1, + server_root=None, + gecko_log=None, + result_callbacks=None, + prefs=None, + test_tags=None, + socket_timeout=None, + startup_timeout=None, + addons=None, + workspace=None, + verbose=0, + emulator=False, + headless=False, + disable_fission=False, + **kwargs + ): + self._appName = None + self._capabilities = None + self._filename_pattern = None + self._version_info = {} + + self.fixture_servers = {} + self.fixtures = Fixtures() + self.extra_kwargs = kwargs + self.test_kwargs = deepcopy(kwargs) + self.address = address + self.app = app + self.app_args = app_args or [] + self.bin = binary + self.emulator = emulator + self.profile = profile + self.addons = addons + self.logger = logger + self.marionette = None + self.logdir = logdir + self.repeat = repeat or 0 + self.run_until_failure = run_until_failure or False + self.symbols_path = symbols_path + self.socket_timeout = socket_timeout + self.startup_timeout = startup_timeout + self.shuffle = shuffle + self.shuffle_seed = shuffle_seed + self.server_root = server_root + self.this_chunk = this_chunk + self.total_chunks = total_chunks + self.mixin_run_tests = [] + self.manifest_skipped_tests = [] + self.tests = [] + self.result_callbacks = result_callbacks or [] + self.prefs = prefs or {} + self.test_tags = test_tags + self.workspace = workspace + # If no workspace is set, default location for gecko.log is . + # and default location for profile is TMP + self.workspace_path = workspace or os.getcwd() + self.verbose = verbose + self.headless = headless + + self.prefs.update({"fission.autostart": not disable_fission}) + + # If no repeat has been set, default to 30 extra runs + if self.run_until_failure and repeat is None: + self.repeat = 30 + + def gather_debug(test, status): + # No screenshots and page source for skipped tests + if status == "SKIP": + return + + rv = {} + marionette = test._marionette_weakref() + + # In the event we're gathering debug without starting a session, + # skip marionette commands + if marionette.session is not None: + try: + with marionette.using_context(marionette.CONTEXT_CHROME): + rv["screenshot"] = marionette.screenshot() + with marionette.using_context(marionette.CONTEXT_CONTENT): + rv["source"] = marionette.page_source + except Exception as exc: + self.logger.warning( + "Failed to gather test failure debug: {}".format(exc) + ) + return rv + + self.result_callbacks.append(gather_debug) + + # testvars are set up in self.testvars property + self._testvars = None + self.testvars_paths = testvars + + self.test_handlers = [] + + self.reset_test_stats() + + self.logger.info( + "Using workspace for temporary data: " '"{}"'.format(self.workspace_path) + ) + + if not gecko_log: + self.gecko_log = os.path.join(self.workspace_path or "", "gecko.log") + else: + self.gecko_log = gecko_log + + self.results = [] + + @property + def filename_pattern(self): + if self._filename_pattern is None: + self._filename_pattern = re.compile("^test(((_.+?)+?\.((py))))$") + + return self._filename_pattern + + @property + def testvars(self): + if self._testvars is not None: + return self._testvars + + self._testvars = {} + + def update(d, u): + """Update a dictionary that may contain nested dictionaries.""" + for k, v in six.iteritems(u): + o = d.get(k, {}) + if isinstance(v, dict) and isinstance(o, dict): + d[k] = update(d.get(k, {}), v) + else: + d[k] = u[k] + return d + + json_testvars = self._load_testvars() + for j in json_testvars: + self._testvars = update(self._testvars, j) + return self._testvars + + def _load_testvars(self): + data = [] + if self.testvars_paths is not None: + for path in list(self.testvars_paths): + path = os.path.abspath(os.path.expanduser(path)) + if not os.path.exists(path): + raise IOError("--testvars file {} does not exist".format(path)) + try: + with open(path) as f: + data.append(json.loads(f.read())) + except ValueError as e: + msg = "JSON file ({0}) is not properly formatted: {1}" + reraise( + ValueError, + ValueError(msg.format(os.path.abspath(path), e)), + sys.exc_info()[2], + ) + return data + + @property + def capabilities(self): + if self._capabilities: + return self._capabilities + + self.marionette.start_session() + self._capabilities = self.marionette.session_capabilities + self.marionette.delete_session() + return self._capabilities + + @property + def appName(self): + if self._appName: + return self._appName + + self._appName = self.capabilities.get("browserName") + return self._appName + + @property + def bin(self): + return self._bin + + @bin.setter + def bin(self, path): + """Set binary and reset parts of runner accordingly. + Intended use: to change binary between calls to run_tests + """ + self._bin = path + self.tests = [] + self.cleanup() + + @property + def version_info(self): + if not self._version_info: + try: + # TODO: Get version_info in Fennec case + self._version_info = mozversion.get_version(binary=self.bin) + except Exception: + self.logger.warning( + "Failed to retrieve version information for {}".format(self.bin) + ) + return self._version_info + + def reset_test_stats(self): + self.passed = 0 + self.failed = 0 + self.crashed = 0 + self.unexpected_successes = 0 + self.todo = 0 + self.skipped = 0 + self.failures = [] + + def _build_kwargs(self): + if self.logdir and not os.access(self.logdir, os.F_OK): + os.mkdir(self.logdir) + + kwargs = { + "socket_timeout": self.socket_timeout, + "prefs": self.prefs, + "startup_timeout": self.startup_timeout, + "verbose": self.verbose, + "symbols_path": self.symbols_path, + } + if self.bin or self.emulator: + kwargs.update( + { + "host": "127.0.0.1", + "port": 2828, + "app": self.app, + "app_args": self.app_args, + "profile": self.profile, + "addons": self.addons, + "gecko_log": self.gecko_log, + # ensure Marionette class takes care of starting gecko instance + "bin": True, + } + ) + + if self.bin: + kwargs.update( + { + "bin": self.bin, + } + ) + + if self.emulator: + kwargs.update( + { + "avd_home": self.extra_kwargs.get("avd_home"), + "adb_path": self.extra_kwargs.get("adb_path"), + "emulator_binary": self.extra_kwargs.get("emulator_bin"), + "avd": self.extra_kwargs.get("avd"), + "package_name": self.extra_kwargs.get("package_name"), + } + ) + + if self.address: + host, port = self.address.split(":") + kwargs.update( + { + "host": host, + "port": int(port), + } + ) + if self.emulator: + kwargs.update( + { + "connect_to_running_emulator": True, + } + ) + if not self.bin and not self.emulator: + try: + # Establish a socket connection so we can vertify the data come back + connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + connection.connect((host, int(port))) + connection.close() + except Exception as e: + exc_cls, _, tb = sys.exc_info() + msg = "Connection attempt to {0}:{1} failed with error: {2}" + reraise(exc_cls, exc_cls(msg.format(host, port, e)), tb) + if self.workspace: + kwargs["workspace"] = self.workspace_path + if self.headless: + kwargs["headless"] = True + + return kwargs + + def record_crash(self): + crash = True + try: + crash = self.marionette.check_for_crash() + self.crashed += int(crash) + except Exception: + traceback.print_exc() + return crash + + def _initialize_test_run(self, tests): + assert len(tests) > 0 + assert len(self.test_handlers) > 0 + self.reset_test_stats() + + def _add_tests(self, tests): + for test in tests: + self.add_test(test) + + invalid_tests = [ + t["filepath"] + for t in self.tests + if not self._is_filename_valid(t["filepath"]) + ] + if invalid_tests: + raise Exception( + "Test file names must be of the form " + "'test_something.py'." + " Invalid test names:\n {}".format("\n ".join(invalid_tests)) + ) + + def _is_filename_valid(self, filename): + filename = os.path.basename(filename) + return self.filename_pattern.match(filename) + + def _fix_test_path(self, path): + """Normalize a logged test path from the test package.""" + test_path_prefixes = [ + "tests{}".format(os.path.sep), + ] + + path = os.path.relpath(path) + for prefix in test_path_prefixes: + if path.startswith(prefix): + path = path[len(prefix) :] + break + path = path.replace("\\", "/") + + return path + + def _log_skipped_tests(self): + for test in self.manifest_skipped_tests: + rel_path = None + if os.path.exists(test["path"]): + rel_path = self._fix_test_path(test["path"]) + + self.logger.test_start(rel_path) + self.logger.test_end(rel_path, "SKIP", message=test["disabled"]) + self.todo += 1 + + def run_tests(self, tests): + start_time = time.time() + self._initialize_test_run(tests) + + if self.marionette is None: + self.marionette = self.driverclass(**self._build_kwargs()) + self.logger.info("Profile path is %s" % self.marionette.profile_path) + + if len(self.fixture_servers) == 0 or any( + not server.is_alive for _, server in self.fixture_servers + ): + self.logger.info("Starting fixture servers") + self.fixture_servers = self.start_fixture_servers() + for url in serve.iter_url(self.fixture_servers): + self.logger.info("Fixture server listening on %s" % url) + + # backwards compatibility + self.marionette.baseurl = serve.where_is("/") + + self._add_tests(tests) + + device_info = None + if self.marionette.instance and self.emulator: + try: + device_info = self.marionette.instance.runner.device.device.get_info() + except Exception: + self.logger.warning("Could not get device info", exc_info=True) + + tests_by_group = defaultdict(list) + for test in self.tests: + group = self._fix_test_path(test["group"]) + filepath = self._fix_test_path(test["filepath"]) + tests_by_group[group].append(filepath) + + self.logger.suite_start( + tests_by_group, + name="marionette-test", + version_info=self.version_info, + device_info=device_info, + ) + + if self.shuffle: + self.logger.info("Using shuffle seed: %d" % self.shuffle_seed) + + self._log_skipped_tests() + + interrupted = None + try: + repeat_index = 0 + while repeat_index <= self.repeat: + if repeat_index > 0: + self.logger.info("\nREPEAT {}\n-------".format(repeat_index)) + self.run_test_sets() + if self.run_until_failure and self.failed > 0: + break + + repeat_index += 1 + + except KeyboardInterrupt: + # in case of KeyboardInterrupt during the test execution + # we want to display current test results. + # so we keep the exception to raise it later. + interrupted = sys.exc_info() + except Exception: + # For any other exception we return immediately and have to + # cleanup running processes + self.cleanup() + raise + + try: + self._print_summary(tests) + self.record_crash() + self.elapsedtime = time.time() - start_time + + for run_tests in self.mixin_run_tests: + run_tests(tests) + + self.logger.suite_end() + except Exception: + # raise only the exception if we were not interrupted + if not interrupted: + raise + finally: + self.cleanup() + + # reraise previous interruption now + if interrupted: + reraise(interrupted[0], interrupted[1], interrupted[2]) + + def _print_summary(self, tests): + self.logger.info("\nSUMMARY\n-------") + self.logger.info("passed: {}".format(self.passed)) + if self.unexpected_successes == 0: + self.logger.info("failed: {}".format(self.failed)) + else: + self.logger.info( + "failed: {0} (unexpected sucesses: {1})".format( + self.failed, self.unexpected_successes + ) + ) + if self.skipped == 0: + self.logger.info("todo: {}".format(self.todo)) + else: + self.logger.info("todo: {0} (skipped: {1})".format(self.todo, self.skipped)) + + if self.failed > 0: + self.logger.info("\nFAILED TESTS\n-------") + for failed_test in self.failures: + self.logger.info("{}".format(failed_test[0])) + + def start_fixture_servers(self): + root = self.server_root or os.path.join(os.path.dirname(here), "www") + if self.appName == "fennec": + return serve.start(root, host=moznetwork.get_ip()) + else: + return serve.start(root) + + def add_test(self, test, expected="pass", group="default"): + filepath = os.path.abspath(test) + + if os.path.isdir(filepath): + for root, dirs, files in os.walk(filepath): + for filename in files: + if filename.endswith(".toml"): + msg_tmpl = ( + "Ignoring manifest '{0}'; running all tests in '{1}'." + " See --help for details." + ) + relpath = os.path.relpath( + os.path.join(root, filename), filepath + ) + self.logger.warning(msg_tmpl.format(relpath, filepath)) + elif self._is_filename_valid(filename): + test_file = os.path.join(root, filename) + self.add_test(test_file) + return + + file_ext = os.path.splitext(os.path.split(filepath)[-1])[1] + + if file_ext == ".toml": + group = filepath + + manifest = TestManifest() + manifest.read(filepath) + + json_path = update_mozinfo(filepath) + mozinfo.update( + { + "appname": self.appName, + "manage_instance": self.marionette.instance is not None, + "headless": self.headless, + } + ) + self.logger.info("mozinfo updated from: {}".format(json_path)) + self.logger.info("mozinfo is: {}".format(mozinfo.info)) + + filters = [] + if self.test_tags: + filters.append(tags(self.test_tags)) + + manifest_tests = manifest.active_tests( + exists=False, disabled=True, filters=filters, **mozinfo.info + ) + if len(manifest_tests) == 0: + self.logger.error( + "No tests to run using specified " + "combination of filters: {}".format(manifest.fmt_filters()) + ) + + target_tests = [] + for test in manifest_tests: + if test.get("disabled"): + self.manifest_skipped_tests.append(test) + else: + target_tests.append(test) + + for i in target_tests: + if not os.path.exists(i["path"]): + raise IOError("test file: {} does not exist".format(i["path"])) + + self.add_test(i["path"], i["expected"], group=group) + return + + self.tests.append({"filepath": filepath, "expected": expected, "group": group}) + + def run_test(self, filepath, expected): + testloader = unittest.TestLoader() + suite = unittest.TestSuite() + self.test_kwargs["expected"] = expected + mod_name = os.path.splitext(os.path.split(filepath)[-1])[0] + for handler in self.test_handlers: + if handler.match(os.path.basename(filepath)): + handler.add_tests_to_suite( + mod_name, + filepath, + suite, + testloader, + self.marionette, + self.fixtures, + self.testvars, + **self.test_kwargs + ) + break + + if suite.countTestCases(): + runner = self.textrunnerclass( + logger=self.logger, + marionette=self.marionette, + capabilities=self.capabilities, + result_callbacks=self.result_callbacks, + ) + + results = runner.run(suite) + self.results.append(results) + + self.failed += len(results.failures) + len(results.errors) + if hasattr(results, "skipped"): + self.skipped += len(results.skipped) + self.todo += len(results.skipped) + self.passed += results.passed + for failure in results.failures + results.errors: + self.failures.append( + (results.getInfo(failure), failure.output, "TEST-UNEXPECTED-FAIL") + ) + if hasattr(results, "unexpectedSuccesses"): + self.failed += len(results.unexpectedSuccesses) + self.unexpected_successes += len(results.unexpectedSuccesses) + for failure in results.unexpectedSuccesses: + self.failures.append( + ( + results.getInfo(failure), + failure.output, + "TEST-UNEXPECTED-PASS", + ) + ) + if hasattr(results, "expectedFailures"): + self.todo += len(results.expectedFailures) + + self.mixin_run_tests = [] + for result in self.results: + result.result_modifiers = [] + + def run_test_set(self, tests): + if self.shuffle: + random.seed(self.shuffle_seed) + random.shuffle(tests) + + for test in tests: + self.run_test(test["filepath"], test["expected"]) + if self.record_crash(): + break + + def run_test_sets(self): + if len(self.tests) < 1: + raise Exception("There are no tests to run.") + elif self.total_chunks is not None and self.total_chunks > len(self.tests): + raise ValueError( + "Total number of chunks must be between 1 and {}.".format( + len(self.tests) + ) + ) + if self.total_chunks is not None and self.total_chunks > 1: + chunks = [[] for i in range(self.total_chunks)] + for i, test in enumerate(self.tests): + target_chunk = i % self.total_chunks + chunks[target_chunk].append(test) + + self.logger.info( + "Running chunk {0} of {1} ({2} tests selected from a " + "total of {3})".format( + self.this_chunk, + self.total_chunks, + len(chunks[self.this_chunk - 1]), + len(self.tests), + ) + ) + self.tests = chunks[self.this_chunk - 1] + + self.run_test_set(self.tests) + + def cleanup(self): + for proc in serve.iter_proc(self.fixture_servers): + proc.stop() + proc.kill() + self.fixture_servers = {} + + if hasattr(self, "marionette") and self.marionette: + if self.marionette.instance is not None: + if self.marionette.instance.runner.is_running(): + # Force a clean shutdown of the application process first if + # it is still running. If that fails, kill the process. + # Therefore a new session needs to be started. + self.marionette.start_session() + self.marionette.quit() + + self.marionette.instance.close(clean=True) + self.marionette.instance = None + + self.marionette.cleanup() + self.marionette = None + + __del__ = cleanup diff --git a/testing/marionette/harness/marionette_harness/runner/httpd.py b/testing/marionette/harness/marionette_harness/runner/httpd.py new file mode 100755 index 0000000000..8ffc85aeb0 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/runner/httpd.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python + +# 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/. + +"""Specialisation of wptserver.server.WebTestHttpd for testing +Marionette. + +""" + +import argparse +import os +import select +import sys +import time + +from six.moves.urllib import parse as urlparse +from wptserve import handlers, request, server +from wptserve import routes as default_routes + +root = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) +default_doc_root = os.path.join(root, "www") +default_ssl_cert = os.path.join(root, "certificates", "test.cert") +default_ssl_key = os.path.join(root, "certificates", "test.key") + + +@handlers.handler +def http_auth_handler(req, response): + # Allow the test to specify the username and password + params = dict(urlparse.parse_qsl(req.url_parts.query)) + username = params.get("username", "guest") + password = params.get("password", "guest") + + auth = request.Authentication(req.headers) + content = """<!doctype html> +<title>HTTP Authentication</title> +<p id="status">{}</p>""" + + if auth.username == username and auth.password == password: + response.status = 200 + response.content = content.format("success") + + else: + response.status = 401 + response.headers.set("WWW-Authenticate", 'Basic realm="secret"') + response.content = content.format("restricted") + + +@handlers.handler +def upload_handler(request, response): + return 200, [], [request.headers.get("Content-Type")] or [] + + +@handlers.handler +def slow_loading_handler(request, response): + # Allow the test specify the delay for delivering the content + params = dict(urlparse.parse_qsl(request.url_parts.query)) + delay = int(params.get("delay", 5)) + time.sleep(delay) + + # Do not allow the page to be cached to circumvent the bfcache of the browser + response.headers.set("Cache-Control", "no-cache, no-store") + response.content = """<!doctype html> +<meta charset="UTF-8"> +<title>Slow page loading</title> + +<p>Delay: <span id="delay">{}</span></p> +""".format( + delay + ) + + +@handlers.handler +def slow_coop_handler(request, response): + # Allow the test specify the delay for delivering the content + params = dict(urlparse.parse_qsl(request.url_parts.query)) + delay = int(params.get("delay", 5)) + time.sleep(delay) + + # Isolate the browsing context exclusively to same-origin documents + response.headers.set("Cross-Origin-Opener-Policy", "same-origin") + response.headers.set("Cache-Control", "no-cache, no-store") + response.content = """<!doctype html> +<meta charset="UTF-8"> +<title>Slow cross-origin page loading</title> + +<p>Delay: <span id="delay">{}</span></p> +""".format( + delay + ) + + +@handlers.handler +def update_xml_handler(request, response): + response.headers.set("Content-Type", "text/xml") + mar_digest = ( + "75cd68e6c98c84c435cd27e353f5b4f6a3f2c50f6802aa9bf62b47e47138757306769fd9befa08793635ee649" + "2319253480860b4aa8ed9ee1caaa4c83ebc90b9" + ) + response.content = """ + <updates> + <update type="minor" displayVersion="9999.0" appVersion="9999.0" platformVersion="9999.0" + buildID="20220627075547"> + <patch type="complete" URL="{}://{}/update/complete.mar" size="86612" + hashFunction="sha512" hashValue="{}"/> + </update> + </updates> + """.format( + request.url_parts.scheme, request.url_parts.netloc, mar_digest + ) + + +class NotAliveError(Exception): + """Occurs when attempting to run a function that requires the HTTPD + to have been started, and it has not. + + """ + + pass + + +class FixtureServer(object): + def __init__( + self, + doc_root, + url="http://127.0.0.1:0", + use_ssl=False, + ssl_cert=None, + ssl_key=None, + ): + if not os.path.isdir(doc_root): + raise ValueError("Server root is not a directory: %s" % doc_root) + + url = urlparse.urlparse(url) + if url.scheme is None: + raise ValueError("Server scheme not provided") + + scheme, host, port = url.scheme, url.hostname, url.port + if host is None: + host = "127.0.0.1" + if port is None: + port = 0 + + routes = [ + ("POST", "/file_upload", upload_handler), + ("GET", "/http_auth", http_auth_handler), + ("GET", "/slow", slow_loading_handler), + ("GET", "/slow-coop", slow_coop_handler), + ("GET", "/update.xml", update_xml_handler), + ] + routes.extend(default_routes.routes) + + self._httpd = server.WebTestHttpd( + host=host, + port=port, + bind_address=True, + doc_root=doc_root, + routes=routes, + use_ssl=True if scheme == "https" else False, + certificate=ssl_cert, + key_file=ssl_key, + ) + + def start(self): + if self.is_alive: + return + self._httpd.start() + + def wait(self): + if not self.is_alive: + return + try: + select.select([], [], []) + except KeyboardInterrupt: + self.stop() + + def stop(self): + if not self.is_alive: + return + self._httpd.stop() + + def get_url(self, path): + if not self.is_alive: + raise NotAliveError() + return self._httpd.get_url(path) + + @property + def doc_root(self): + return self._httpd.router.doc_root + + @property + def router(self): + return self._httpd.router + + @property + def routes(self): + return self._httpd.router.routes + + @property + def is_alive(self): + return self._httpd.started + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Specialised HTTP server for testing Marionette." + ) + parser.add_argument( + "url", + help=""" +service address including scheme, hostname, port, and prefix for document root, +e.g. \"https://0.0.0.0:0/base/\"""", + ) + parser.add_argument( + "-r", + dest="doc_root", + default=default_doc_root, + help="path to document root (default %(default)s)", + ) + parser.add_argument( + "-c", + dest="ssl_cert", + default=default_ssl_cert, + help="path to SSL certificate (default %(default)s)", + ) + parser.add_argument( + "-k", + dest="ssl_key", + default=default_ssl_key, + help="path to SSL certificate key (default %(default)s)", + ) + args = parser.parse_args() + + httpd = FixtureServer( + args.doc_root, args.url, ssl_cert=args.ssl_cert, ssl_key=args.ssl_key + ) + httpd.start() + print( + "{0}: started fixture server on {1}".format(sys.argv[0], httpd.get_url("/")), + file=sys.stderr, + ) + httpd.wait() diff --git a/testing/marionette/harness/marionette_harness/runner/mixins/__init__.py b/testing/marionette/harness/marionette_harness/runner/mixins/__init__.py new file mode 100644 index 0000000000..71b13461d5 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/runner/mixins/__init__.py @@ -0,0 +1,5 @@ +# 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 .window_manager import WindowManagerMixin diff --git a/testing/marionette/harness/marionette_harness/runner/mixins/window_manager.py b/testing/marionette/harness/marionette_harness/runner/mixins/window_manager.py new file mode 100644 index 0000000000..85729cc585 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/runner/mixins/window_manager.py @@ -0,0 +1,210 @@ +# 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 sys + +from marionette_driver import Wait +from six import reraise + + +class WindowManagerMixin(object): + def setUp(self): + super(WindowManagerMixin, self).setUp() + + self.start_window = self.marionette.current_chrome_window_handle + self.start_windows = self.marionette.chrome_window_handles + + self.start_tab = self.marionette.current_window_handle + self.start_tabs = self.marionette.window_handles + + def tearDown(self): + if len(self.marionette.chrome_window_handles) > len(self.start_windows): + raise Exception("Not all windows as opened by the test have been closed") + + if len(self.marionette.window_handles) > len(self.start_tabs): + raise Exception("Not all tabs as opened by the test have been closed") + + super(WindowManagerMixin, self).tearDown() + + def close_all_tabs(self): + current_window_handles = self.marionette.window_handles + + # If the start tab is not present anymore, use the next one of the list + if self.start_tab not in current_window_handles: + self.start_tab = current_window_handles[0] + + current_window_handles.remove(self.start_tab) + for handle in current_window_handles: + self.marionette.switch_to_window(handle) + self.marionette.close() + + self.marionette.switch_to_window(self.start_tab) + + def close_all_windows(self): + current_chrome_window_handles = self.marionette.chrome_window_handles + + # If the start window is not present anymore, use the next one of the list + if self.start_window not in current_chrome_window_handles: + self.start_window = current_chrome_window_handles[0] + current_chrome_window_handles.remove(self.start_window) + + for handle in current_chrome_window_handles: + self.marionette.switch_to_window(handle) + self.marionette.close_chrome_window() + + self.marionette.switch_to_window(self.start_window) + + def open_tab(self, callback=None, focus=False): + current_tabs = self.marionette.window_handles + + try: + if callable(callback): + callback() + else: + result = self.marionette.open(type="tab", focus=focus) + if result["type"] != "tab": + raise Exception( + "Newly opened browsing context is of type {} and not tab.".format( + result["type"] + ) + ) + except Exception: + exc_cls, exc, tb = sys.exc_info() + reraise( + exc_cls, + exc_cls("Failed to trigger opening a new tab: {}".format(exc)), + tb, + ) + else: + Wait(self.marionette).until( + lambda mn: len(mn.window_handles) == len(current_tabs) + 1, + message="No new tab has been opened", + ) + + [new_tab] = list(set(self.marionette.window_handles) - set(current_tabs)) + + return new_tab + + def open_window(self, callback=None, focus=False, private=False): + current_windows = self.marionette.chrome_window_handles + current_tabs = self.marionette.window_handles + + def loaded(handle): + with self.marionette.using_context("chrome"): + return self.marionette.execute_script( + """ + const { windowManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/WindowManager.sys.mjs" + ); + const win = windowManager.findWindowByHandle(arguments[0]).win; + return win.document.readyState == "complete"; + """, + script_args=[handle], + ) + + try: + if callable(callback): + callback(focus) + else: + result = self.marionette.open( + type="window", focus=focus, private=private + ) + if result["type"] != "window": + raise Exception( + "Newly opened browsing context is of type {} and not window.".format( + result["type"] + ) + ) + except Exception: + exc_cls, exc, tb = sys.exc_info() + reraise( + exc_cls, + exc_cls("Failed to trigger opening a new window: {}".format(exc)), + tb, + ) + else: + Wait(self.marionette).until( + lambda mn: len(mn.chrome_window_handles) == len(current_windows) + 1, + message="No new window has been opened", + ) + + [new_window] = list( + set(self.marionette.chrome_window_handles) - set(current_windows) + ) + + # Before continuing ensure the window has been completed loading + Wait(self.marionette).until( + lambda _: loaded(new_window), + message="Window with handle '{}'' did not finish loading".format( + new_window + ), + ) + + # Bug 1507771 - Return the correct handle based on the currently selected context + # as long as "WebDriver:NewWindow" is not handled separtely in chrome context + context = self.marionette._send_message( + "Marionette:GetContext", key="value" + ) + if context == "chrome": + return new_window + elif context == "content": + [new_tab] = list( + set(self.marionette.window_handles) - set(current_tabs) + ) + return new_tab + + def open_chrome_window(self, url, focus=False): + """Open a new chrome window with the specified chrome URL. + + Can be replaced with "WebDriver:NewWindow" once the command + supports opening generic chrome windows beside browsers (bug 1507771). + """ + + def open_with_js(focus): + with self.marionette.using_context("chrome"): + self.marionette.execute_async_script( + """ + let [url, focus, resolve] = arguments; + + function waitForEvent(target, type, args) { + return new Promise(resolve => { + let params = Object.assign({once: true}, args); + target.addEventListener(type, event => { + dump(`** Received DOM event ${event.type} for ${event.target}\n`); + resolve(); + }, params); + }); + } + + function waitForFocus(win) { + return Promise.all([ + waitForEvent(win, "activate"), + waitForEvent(win, "focus", {capture: true}), + ]); + } + + (async function() { + // Open a window, wait for it to receive focus + let win = window.openDialog(url, null, "chrome,centerscreen"); + let focused = waitForFocus(win); + + win.focus(); + await focused; + + // The new window shouldn't get focused. As such set the + // focus back to the opening window. + if (!focus && Services.focus.activeWindow != window) { + let focused = waitForFocus(window); + window.focus(); + await focused; + } + + resolve(win.docShell.browsingContext.id); + })(); + """, + script_args=(url, focus), + ) + + with self.marionette.using_context("chrome"): + return self.open_window(callback=open_with_js, focus=focus) diff --git a/testing/marionette/harness/marionette_harness/runner/serve.py b/testing/marionette/harness/marionette_harness/runner/serve.py new file mode 100755 index 0000000000..3833bbe876 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/runner/serve.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python + +# 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/. + +"""Spawns necessary HTTP servers for testing Marionette in child +processes. + +""" + +import argparse +import multiprocessing +import os +import sys +from collections import defaultdict + +from six import iteritems + +from . import httpd + +__all__ = [ + "default_doc_root", + "iter_proc", + "iter_url", + "registered_servers", + "servers", + "start", + "where_is", +] +here = os.path.abspath(os.path.dirname(__file__)) + + +class BlockingChannel(object): + def __init__(self, channel): + self.chan = channel + self.lock = multiprocessing.Lock() + + def call(self, func, args=()): + self.send((func, args)) + return self.recv() + + def send(self, *args): + try: + self.lock.acquire() + self.chan.send(args) + finally: + self.lock.release() + + def recv(self): + try: + self.lock.acquire() + payload = self.chan.recv() + if isinstance(payload, tuple) and len(payload) == 1: + return payload[0] + return payload + except KeyboardInterrupt: + return ("stop", ()) + finally: + self.lock.release() + + +class ServerProxy(multiprocessing.Process, BlockingChannel): + def __init__(self, channel, init_func, *init_args, **init_kwargs): + multiprocessing.Process.__init__(self) + BlockingChannel.__init__(self, channel) + self.init_func = init_func + self.init_args = init_args + self.init_kwargs = init_kwargs + + def run(self): + try: + server = self.init_func(*self.init_args, **self.init_kwargs) + server.start() + self.send(("ok", ())) + + while True: + # ["func", ("arg", ...)] + # ["prop", ()] + sattr, fargs = self.recv() + attr = getattr(server, sattr) + + # apply fargs to attr if it is a function + if callable(attr): + rv = attr(*fargs) + + # otherwise attr is a property + else: + rv = attr + + self.send(rv) + + if sattr == "stop": + return + + except Exception as e: + self.send(("stop", e)) + + except KeyboardInterrupt: + server.stop() + + +class ServerProc(BlockingChannel): + def __init__(self, init_func): + self._init_func = init_func + self.proc = None + + parent_chan, self.child_chan = multiprocessing.Pipe() + BlockingChannel.__init__(self, parent_chan) + + def start(self, doc_root, ssl_config, **kwargs): + self.proc = ServerProxy( + self.child_chan, self._init_func, doc_root, ssl_config, **kwargs + ) + self.proc.daemon = True + self.proc.start() + + res, exc = self.recv() + if res == "stop": + raise exc + + def get_url(self, url): + return self.call("get_url", (url,)) + + @property + def doc_root(self): + return self.call("doc_root", ()) + + def stop(self): + self.call("stop") + if not self.is_alive: + return + self.proc.join() + + def kill(self): + if not self.is_alive: + return + self.proc.terminate() + self.proc.join(0) + + @property + def is_alive(self): + if self.proc is not None: + return self.proc.is_alive() + return False + + +def http_server(doc_root, ssl_config, host="127.0.0.1", **kwargs): + return httpd.FixtureServer(doc_root, url="http://{}:0/".format(host), **kwargs) + + +def https_server(doc_root, ssl_config, host="127.0.0.1", **kwargs): + return httpd.FixtureServer( + doc_root, + url="https://{}:0/".format(host), + ssl_key=ssl_config["key_path"], + ssl_cert=ssl_config["cert_path"], + **kwargs + ) + + +def start_servers(doc_root, ssl_config, **kwargs): + servers = defaultdict() + for schema, builder_fn in registered_servers: + proc = ServerProc(builder_fn) + proc.start(doc_root, ssl_config, **kwargs) + servers[schema] = (proc.get_url("/"), proc) + return servers + + +def start(doc_root=None, **kwargs): + """Start all relevant test servers. + + If no `doc_root` is given the default + testing/marionette/harness/marionette_harness/www directory will be used. + + Additional keyword arguments can be given which will be passed on + to the individual ``FixtureServer``'s in httpd.py. + + """ + doc_root = doc_root or default_doc_root + ssl_config = { + "cert_path": httpd.default_ssl_cert, + "key_path": httpd.default_ssl_key, + } + + global servers + servers = start_servers(doc_root, ssl_config, **kwargs) + return servers + + +def where_is(uri, on="http"): + """Returns the full URL, including scheme, hostname, and port, for + a fixture resource from the server associated with the ``on`` key. + It will by default look for the resource in the "http" server. + + """ + return servers.get(on)[1].get_url(uri) + + +def iter_proc(servers): + for _, (_, proc) in iteritems(servers): + yield proc + + +def iter_url(servers): + for _, (url, _) in iteritems(servers): + yield url + + +default_doc_root = os.path.join(os.path.dirname(here), "www") +registered_servers = [("http", http_server), ("https", https_server)] +servers = defaultdict() + + +def main(args): + global servers + + parser = argparse.ArgumentParser() + parser.add_argument( + "-r", dest="doc_root", help="Path to document root. Overrides default." + ) + args = parser.parse_args() + + servers = start(args.doc_root) + for url in iter_url(servers): + print("{}: listening on {}".format(sys.argv[0], url), file=sys.stderr) + + try: + while any(proc.is_alive for proc in iter_proc(servers)): + for proc in iter_proc(servers): + proc.proc.join(1) + except KeyboardInterrupt: + for proc in iter_proc(servers): + proc.kill() + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/testing/marionette/harness/marionette_harness/runtests.py b/testing/marionette/harness/marionette_harness/runtests.py new file mode 100644 index 0000000000..0d86e1534d --- /dev/null +++ b/testing/marionette/harness/marionette_harness/runtests.py @@ -0,0 +1,115 @@ +# 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 sys + +import mozlog +from marionette_driver import __version__ as driver_version + +from marionette_harness import ( + BaseMarionetteArguments, + BaseMarionetteTestRunner, + MarionetteTestCase, + __version__, +) + + +class MarionetteTestRunner(BaseMarionetteTestRunner): + def __init__(self, **kwargs): + BaseMarionetteTestRunner.__init__(self, **kwargs) + self.test_handlers = [MarionetteTestCase] + + +class MarionetteArguments(BaseMarionetteArguments): + pass + + +class MarionetteHarness(object): + def __init__( + self, + runner_class=MarionetteTestRunner, + parser_class=MarionetteArguments, + testcase_class=MarionetteTestCase, + args=None, + ): + self._runner_class = runner_class + self._parser_class = parser_class + self._testcase_class = testcase_class + self.args = args or self.parse_args() + + def parse_args(self, logger_defaults=None): + parser = self._parser_class( + usage="%(prog)s [options] test_file_or_dir <test_file_or_dir> ..." + ) + parser.add_argument( + "--version", + action="version", + help="Show version information.", + version="%(prog)s {version}" + " (using marionette-driver: {driver_version}, ".format( + version=__version__, driver_version=driver_version + ), + ) + mozlog.commandline.add_logging_group(parser) + args = parser.parse_args() + parser.verify_usage(args) + + logger = mozlog.commandline.setup_logging( + args.logger_name, args, logger_defaults or {"tbpl": sys.stdout} + ) + + args.logger = logger + return vars(args) + + def process_args(self): + if self.args.get("pydebugger"): + self._testcase_class.pydebugger = __import__(self.args["pydebugger"]) + # Remove mozlog arguments from the return value since these aren't + # used directly by the rest of marionette + self.args = { + key: value for key, value in self.args.items() if not key.startswith("log_") + } + + def run(self): + self.process_args() + tests = self.args.pop("tests") + runner = self._runner_class(**self.args) + try: + runner.run_tests(tests) + finally: + runner.cleanup() + return runner.failed + runner.crashed + + +def cli( + runner_class=MarionetteTestRunner, + parser_class=MarionetteArguments, + harness_class=MarionetteHarness, + testcase_class=MarionetteTestCase, + args=None, +): + """ + Call the harness to parse args and run tests. + + The following exit codes are expected: + - Test failures: 10 + - Harness/other failures: 1 + - Success: 0 + """ + logger = mozlog.commandline.setup_logging("Marionette test runner", {}) + try: + harness_instance = harness_class( + runner_class, parser_class, testcase_class, args=args + ) + failed = harness_instance.run() + if failed > 0: + sys.exit(10) + except Exception as e: + logger.error(str(e), exc_info=True) + sys.exit(1) + sys.exit(0) + + +if __name__ == "__main__": + cli() diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/conftest.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/conftest.py new file mode 100644 index 0000000000..43951b2c04 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/conftest.py @@ -0,0 +1,99 @@ +# 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 pytest + +from unittest.mock import Mock, MagicMock + +from marionette_driver.marionette import Marionette + +from marionette_harness.runner.httpd import FixtureServer + + +@pytest.fixture(scope="module") +def logger(): + """ + Fake logger to help with mocking out other runner-related classes. + """ + import mozlog + + return Mock(spec=mozlog.structuredlog.StructuredLogger) + + +@pytest.fixture +def mach_parsed_kwargs(logger): + """ + Parsed and verified dictionary used during simplest + call to mach marionette-test + """ + return { + "adb_path": None, + "addons": None, + "address": None, + "app": None, + "app_args": [], + "avd": None, + "avd_home": None, + "binary": "/path/to/firefox", + "browsermob_port": None, + "browsermob_script": None, + "device_serial": None, + "emulator": False, + "emulator_bin": None, + "gecko_log": None, + "jsdebugger": False, + "log_errorsummary": None, + "log_html": None, + "log_mach": None, + "log_mach_buffer": None, + "log_mach_level": None, + "log_mach_verbose": None, + "log_raw": None, + "log_raw_level": None, + "log_tbpl": None, + "log_tbpl_buffer": None, + "log_tbpl_compact": None, + "log_tbpl_level": None, + "log_unittest": None, + "log_xunit": None, + "logger_name": "Marionette-based Tests", + "prefs": {}, + "prefs_args": None, + "prefs_files": None, + "profile": None, + "pydebugger": None, + "repeat": None, + "run_until_failure": None, + "server_root": None, + "shuffle": False, + "shuffle_seed": 2276870381009474531, + "socket_timeout": 60.0, + "startup_timeout": 60, + "symbols_path": None, + "test_tags": None, + "tests": ["/path/to/unit-tests.toml"], + "testvars": None, + "this_chunk": None, + "timeout": None, + "total_chunks": None, + "verbose": None, + "workspace": None, + "logger": logger, + } + + +@pytest.fixture +def mock_httpd(request): + """Mock httpd instance""" + httpd = MagicMock(spec=FixtureServer) + return httpd + + +@pytest.fixture +def mock_marionette(request): + """Mock marionette instance""" + marionette = MagicMock(spec=dir(Marionette())) + if "has_crashed" in request.fixturenames: + marionette.check_for_crash.return_value = request.getfixturevalue("has_crashed") + return marionette diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/python.toml b/testing/marionette/harness/marionette_harness/tests/harness_unit/python.toml new file mode 100644 index 0000000000..7ae7a32440 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/python.toml @@ -0,0 +1,14 @@ +[DEFAULT] +subsuite = "marionette-harness" + +["test_httpd.py"] + +["test_marionette_arguments.py"] + +["test_marionette_harness.py"] + +["test_marionette_runner.py"] + +["test_marionette_test_result.py"] + +["test_serve.py"] diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/test_httpd.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_httpd.py new file mode 100644 index 0000000000..b62e731ff1 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_httpd.py @@ -0,0 +1,92 @@ +# 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 os +import types + +import six +from six.moves.urllib_request import urlopen + +import mozunit +import pytest + +from wptserve.handlers import json_handler + +from marionette_harness.runner import httpd + +here = os.path.abspath(os.path.dirname(__file__)) +parent = os.path.dirname(here) +default_doc_root = os.path.join(os.path.dirname(parent), "www") + + +@pytest.fixture +def server(): + server = httpd.FixtureServer(default_doc_root) + yield server + server.stop() + + +def test_ctor(): + with pytest.raises(ValueError): + httpd.FixtureServer("foo") + httpd.FixtureServer(default_doc_root) + + +def test_start_stop(server): + server.start() + server.stop() + + +def test_get_url(server): + server.start() + url = server.get_url("/") + assert isinstance(url, six.string_types) + assert "http://" in url + + server.stop() + with pytest.raises(httpd.NotAliveError): + server.get_url("/") + + +def test_doc_root(server): + server.start() + assert isinstance(server.doc_root, six.string_types) + server.stop() + assert isinstance(server.doc_root, six.string_types) + + +def test_router(server): + assert server.router is not None + + +def test_routes(server): + assert server.routes is not None + + +def test_is_alive(server): + assert server.is_alive == False + server.start() + assert server.is_alive == True + + +def test_handler(server): + counter = 0 + + @json_handler + def handler(request, response): + return {"count": counter} + + route = ("GET", "/httpd/test_handler", handler) + server.router.register(*route) + server.start() + + url = server.get_url("/httpd/test_handler") + body = urlopen(url).read() + res = json.loads(body) + assert res["count"] == counter + + +if __name__ == "__main__": + mozunit.main("-p", "no:terminalreporter", "--log-tbpl=-", "--capture", "no") diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_arguments.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_arguments.py new file mode 100644 index 0000000000..b640741a6f --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_arguments.py @@ -0,0 +1,80 @@ +# 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 mozunit +import pytest + +from marionette_harness.runtests import MarionetteArguments, MarionetteTestRunner + + +@pytest.mark.parametrize("socket_timeout", ["A", "10", "1B-", "1C2", "44.35"]) +def test_parse_arg_socket_timeout(socket_timeout): + argv = ["marionette", "--socket-timeout", socket_timeout] + parser = MarionetteArguments() + + def _is_float_convertible(value): + try: + float(value) + return True + except ValueError: + return False + + if not _is_float_convertible(socket_timeout): + with pytest.raises(SystemExit) as ex: + parser.parse_args(args=argv) + assert ex.value.code == 2 + else: + args = parser.parse_args(args=argv) + assert hasattr(args, "socket_timeout") and args.socket_timeout == float( + socket_timeout + ) + + +@pytest.mark.parametrize( + "arg_name, arg_dest, arg_value, expected_value", + [ + ("app-arg", "app_args", "samplevalue", ["samplevalue"]), + ("symbols-path", "symbols_path", "samplevalue", "samplevalue"), + ("gecko-log", "gecko_log", "samplevalue", "samplevalue"), + ("app", "app", "samplevalue", "samplevalue"), + ], +) +def test_parsing_optional_arguments( + mach_parsed_kwargs, arg_name, arg_dest, arg_value, expected_value +): + parser = MarionetteArguments() + parsed_args = parser.parse_args(["--" + arg_name, arg_value]) + result = vars(parsed_args) + assert result.get(arg_dest) == expected_value + mach_parsed_kwargs[arg_dest] = result[arg_dest] + runner = MarionetteTestRunner(**mach_parsed_kwargs) + built_kwargs = runner._build_kwargs() + assert built_kwargs[arg_dest] == expected_value + + +@pytest.mark.parametrize( + "arg_name, arg_dest, arg_value, expected_value", + [ + ("adb", "adb_path", "samplevalue", "samplevalue"), + ("avd", "avd", "samplevalue", "samplevalue"), + ("avd-home", "avd_home", "samplevalue", "samplevalue"), + ("package", "package_name", "samplevalue", "samplevalue"), + ], +) +def test_parse_opt_args_emulator( + mach_parsed_kwargs, arg_name, arg_dest, arg_value, expected_value +): + parser = MarionetteArguments() + parsed_args = parser.parse_args(["--" + arg_name, arg_value]) + result = vars(parsed_args) + assert result.get(arg_dest) == expected_value + mach_parsed_kwargs[arg_dest] = result[arg_dest] + mach_parsed_kwargs["emulator"] = True + runner = MarionetteTestRunner(**mach_parsed_kwargs) + built_kwargs = runner._build_kwargs() + assert built_kwargs[arg_dest] == expected_value + + +if __name__ == "__main__": + mozunit.main("-p", "no:terminalreporter", "--log-tbpl=-", "--capture", "no") diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_harness.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_harness.py new file mode 100644 index 0000000000..b528594381 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_harness.py @@ -0,0 +1,110 @@ +# 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 mozunit +import pytest + +from unittest.mock import Mock, patch, sentinel + +import marionette_harness.marionette_test as marionette_test + +from marionette_harness.runtests import MarionetteTestRunner, MarionetteHarness, cli + + +@pytest.fixture +def harness_class(request): + """ + Mock based on MarionetteHarness whose run method just returns a number of + failures according to the supplied test parameter + """ + if "num_fails_crashed" in request.fixturenames: + num_fails_crashed = request.getfixturevalue("num_fails_crashed") + else: + num_fails_crashed = (0, 0) + harness_cls = Mock(spec=MarionetteHarness) + harness = harness_cls.return_value + if num_fails_crashed is None: + harness.run.side_effect = Exception + else: + harness.run.return_value = sum(num_fails_crashed) + return harness_cls + + +@pytest.fixture +def runner_class(request): + """ + Mock based on MarionetteTestRunner, wherein the runner.failed, + runner.crashed attributes are provided by a test parameter + """ + if "num_fails_crashed" in request.fixturenames: + failures, crashed = request.getfixturevalue("num_fails_crashed") + else: + failures = 0 + crashed = 0 + mock_runner_class = Mock(spec=MarionetteTestRunner) + runner = mock_runner_class.return_value + runner.failed = failures + runner.crashed = crashed + return mock_runner_class + + +@pytest.mark.parametrize( + "num_fails_crashed,exit_code", + [((0, 0), 0), ((1, 0), 10), ((0, 1), 10), (None, 1)], +) +def test_cli_exit_code(num_fails_crashed, exit_code, harness_class): + with pytest.raises(SystemExit) as err: + cli(harness_class=harness_class) + assert err.value.code == exit_code + + +@pytest.mark.parametrize("num_fails_crashed", [(0, 0), (1, 0), (1, 1)]) +def test_call_harness_with_parsed_args_yields_num_failures( + mach_parsed_kwargs, runner_class, num_fails_crashed +): + with patch( + "marionette_harness.runtests.MarionetteHarness.parse_args" + ) as parse_args: + failed_or_crashed = MarionetteHarness( + runner_class, args=mach_parsed_kwargs + ).run() + parse_args.assert_not_called() + assert failed_or_crashed == sum(num_fails_crashed) + + +def test_call_harness_with_no_args_yields_num_failures(runner_class): + with patch( + "marionette_harness.runtests.MarionetteHarness.parse_args", + return_value={"tests": []}, + ) as parse_args: + failed_or_crashed = MarionetteHarness(runner_class).run() + assert parse_args.call_count == 1 + assert failed_or_crashed == 0 + + +def test_args_passed_to_runner_class(mach_parsed_kwargs, runner_class): + arg_list = list(mach_parsed_kwargs.keys()) + arg_list.remove("tests") + mach_parsed_kwargs.update([(a, getattr(sentinel, a)) for a in arg_list]) + harness = MarionetteHarness(runner_class, args=mach_parsed_kwargs) + harness.process_args = Mock() + harness.run() + for arg in arg_list: + assert harness._runner_class.call_args[1][arg] is getattr(sentinel, arg) + + +def test_harness_sets_up_default_test_handlers(mach_parsed_kwargs): + """ + If the necessary TestCase is not in test_handlers, + tests are omitted silently + """ + harness = MarionetteHarness(args=mach_parsed_kwargs) + mach_parsed_kwargs.pop("tests") + runner = harness._runner_class(**mach_parsed_kwargs) + assert marionette_test.MarionetteTestCase in runner.test_handlers + + +if __name__ == "__main__": + mozunit.main("-p", "no:terminalreporter", "--log-tbpl=-", "--capture", "no") diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_runner.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_runner.py new file mode 100644 index 0000000000..fc1a1c70ee --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_runner.py @@ -0,0 +1,541 @@ +# 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 manifestparser +import mozinfo +import mozunit +import pytest + +from unittest.mock import Mock, patch, mock_open, sentinel, DEFAULT + +from marionette_harness.runtests import MarionetteTestRunner + + +@pytest.fixture +def runner(mach_parsed_kwargs): + """ + MarionetteTestRunner instance initialized with default options. + """ + return MarionetteTestRunner(**mach_parsed_kwargs) + + +@pytest.fixture +def mock_runner(runner, mock_marionette, monkeypatch): + """ + MarionetteTestRunner instance with mocked-out + self.marionette and other properties, + to enable testing runner.run_tests(). + """ + runner.driverclass = Mock(return_value=mock_marionette) + for attr in ["run_test", "_capabilities"]: + setattr(runner, attr, Mock()) + runner._appName = "fake_app" + monkeypatch.setattr("marionette_harness.runner.base.mozversion", Mock()) + return runner + + +@pytest.fixture +def build_kwargs_using(mach_parsed_kwargs): + """Helper function for test_build_kwargs_* functions""" + + def kwarg_builder(new_items, return_socket=False): + mach_parsed_kwargs.update(new_items) + runner = MarionetteTestRunner(**mach_parsed_kwargs) + with patch("marionette_harness.runner.base.socket") as socket: + built_kwargs = runner._build_kwargs() + if return_socket: + return built_kwargs, socket + return built_kwargs + + return kwarg_builder + + +@pytest.fixture +def expected_driver_args(runner): + """Helper fixture for tests of _build_kwargs + with binary/emulator. + Provides a dictionary of certain arguments + related to binary/emulator settings + which we expect to be passed to the + driverclass constructor. Expected values can + be updated in tests as needed. + Provides convenience methods for comparing the + expected arguments to the argument dictionary + created by _build_kwargs.""" + + class ExpectedDict(dict): + def assert_matches(self, actual): + for k, v in self.items(): + assert actual[k] == v + + def assert_keys_not_in(self, actual): + for k in self.keys(): + assert k not in actual + + expected = ExpectedDict(host=None, port=None, bin=None) + for attr in ["app", "app_args", "profile", "addons", "gecko_log"]: + expected[attr] = getattr(runner, attr) + return expected + + +class ManifestFixture: + def __init__( + self, + name="mock_manifest", + tests=[{"path": "test_something.py", "expected": "pass"}], + ): + self.filepath = "/path/to/fake/manifest.toml" + self.n_disabled = len([t for t in tests if "disabled" in t]) + self.n_enabled = len(tests) - self.n_disabled + mock_manifest = Mock( + spec=manifestparser.TestManifest, active_tests=Mock(return_value=tests) + ) + self.manifest_class = Mock(return_value=mock_manifest) + self.__repr__ = lambda: "<ManifestFixture {}>".format(name) + + +@pytest.fixture +def manifest(): + return ManifestFixture() + + +@pytest.fixture(params=["enabled", "disabled", "enabled_disabled", "empty"]) +def manifest_with_tests(request): + """ + Fixture for the contents of mock_manifest, where a manifest + can include enabled tests, disabled tests, both, or neither (empty) + """ + included = [] + if "enabled" in request.param: + included += [ + ("test_expected_pass.py", "pass"), + ("test_expected_fail.py", "fail"), + ] + if "disabled" in request.param: + included += [ + ("test_pass_disabled.py", "pass", "skip-if: true"), + ("test_fail_disabled.py", "fail", "skip-if: true"), + ] + keys = ("path", "expected", "disabled") + active_tests = [dict(list(zip(keys, values))) for values in included] + + return ManifestFixture(request.param, active_tests) + + +def test_args_passed_to_driverclass(mock_runner): + built_kwargs = {"arg1": "value1", "arg2": "value2"} + mock_runner._build_kwargs = Mock(return_value=built_kwargs) + with pytest.raises(IOError): + mock_runner.run_tests(["fake_tests.toml"]) + assert mock_runner.driverclass.call_args[1] == built_kwargs + + +def test_build_kwargs_basic_args(build_kwargs_using): + """Test the functionality of runner._build_kwargs: + make sure that basic arguments (those which should + always be included, irrespective of the runner's settings) + get passed to the call to runner.driverclass""" + + basic_args = [ + "socket_timeout", + "prefs", + "startup_timeout", + "verbose", + "symbols_path", + ] + args_dict = {a: getattr(sentinel, a) for a in basic_args} + # Mock an update method to work with calls to MarionetteTestRunner() + args_dict["prefs"].update = Mock(return_value={}) + built_kwargs = build_kwargs_using([(a, getattr(sentinel, a)) for a in basic_args]) + for arg in basic_args: + assert built_kwargs[arg] is getattr(sentinel, arg) + + +@pytest.mark.parametrize("workspace", ["path/to/workspace", None]) +def test_build_kwargs_with_workspace(build_kwargs_using, workspace): + built_kwargs = build_kwargs_using({"workspace": workspace}) + if workspace: + assert built_kwargs["workspace"] == workspace + else: + assert "workspace" not in built_kwargs + + +@pytest.mark.parametrize("address", ["host:123", None]) +def test_build_kwargs_with_address(build_kwargs_using, address): + built_kwargs, socket = build_kwargs_using( + {"address": address, "binary": None, "emulator": None}, return_socket=True + ) + assert "connect_to_running_emulator" not in built_kwargs + if address is not None: + host, port = address.split(":") + assert built_kwargs["host"] == host and built_kwargs["port"] == int(port) + socket.socket().connect.assert_called_with((host, int(port))) + assert socket.socket().close.called + else: + assert not socket.socket.called + + +@pytest.mark.parametrize("address", ["host:123", None]) +@pytest.mark.parametrize("binary", ["path/to/bin", None]) +def test_build_kwargs_with_binary_or_address( + expected_driver_args, build_kwargs_using, binary, address +): + built_kwargs = build_kwargs_using( + {"binary": binary, "address": address, "emulator": None} + ) + if binary: + expected_driver_args["bin"] = binary + if address: + host, port = address.split(":") + expected_driver_args.update({"host": host, "port": int(port)}) + else: + expected_driver_args.update({"host": "127.0.0.1", "port": 2828}) + expected_driver_args.assert_matches(built_kwargs) + elif address is None: + expected_driver_args.assert_keys_not_in(built_kwargs) + + +@pytest.mark.parametrize("address", ["host:123", None]) +@pytest.mark.parametrize("emulator", [True, False, None]) +def test_build_kwargs_with_emulator_or_address( + expected_driver_args, build_kwargs_using, emulator, address +): + emulator_props = [ + (a, getattr(sentinel, a)) for a in ["avd_home", "adb_path", "emulator_bin"] + ] + built_kwargs = build_kwargs_using( + [("emulator", emulator), ("address", address), ("binary", None)] + + emulator_props + ) + if emulator: + expected_driver_args.update(emulator_props) + expected_driver_args["emulator_binary"] = expected_driver_args.pop( + "emulator_bin" + ) + expected_driver_args["bin"] = True + if address: + expected_driver_args["connect_to_running_emulator"] = True + host, port = address.split(":") + expected_driver_args.update({"host": host, "port": int(port)}) + else: + expected_driver_args.update({"host": "127.0.0.1", "port": 2828}) + assert "connect_to_running_emulator" not in built_kwargs + expected_driver_args.assert_matches(built_kwargs) + elif not address: + expected_driver_args.assert_keys_not_in(built_kwargs) + + +def test_parsing_testvars(mach_parsed_kwargs): + mach_parsed_kwargs.pop("tests") + testvars_json_loads = [ + {"wifi": {"ssid": "blah", "keyManagement": "WPA-PSK", "psk": "foo"}}, + {"wifi": {"PEAP": "bar"}, "device": {"stuff": "buzz"}}, + ] + expected_dict = { + "wifi": { + "ssid": "blah", + "keyManagement": "WPA-PSK", + "psk": "foo", + "PEAP": "bar", + }, + "device": {"stuff": "buzz"}, + } + with patch( + "marionette_harness.runtests.MarionetteTestRunner._load_testvars", + return_value=testvars_json_loads, + ) as load: + runner = MarionetteTestRunner(**mach_parsed_kwargs) + assert runner.testvars == expected_dict + assert load.call_count == 1 + + +def test_load_testvars_throws_expected_errors(mach_parsed_kwargs): + mach_parsed_kwargs["testvars"] = ["some_bad_path.json"] + runner = MarionetteTestRunner(**mach_parsed_kwargs) + with pytest.raises(IOError) as io_exc: + runner._load_testvars() + assert "does not exist" in str(io_exc.value) + with patch("os.path.exists", return_value=True): + with patch( + "marionette_harness.runner.base.open", + mock_open(read_data="[not {valid JSON]"), + ): + with pytest.raises(Exception) as json_exc: + runner._load_testvars() + assert "not properly formatted" in str(json_exc.value) + + +def _check_crash_counts(has_crashed, runner, mock_marionette): + if has_crashed: + assert mock_marionette.check_for_crash.call_count == 1 + assert runner.crashed == 1 + else: + assert runner.crashed == 0 + + +@pytest.mark.parametrize("has_crashed", [True, False]) +def test_increment_crash_count_in_run_test_set(runner, has_crashed, mock_marionette): + fake_tests = [{"filepath": i, "expected": "pass"} for i in "abc"] + + with patch.multiple(runner, run_test=DEFAULT, marionette=mock_marionette): + runner.run_test_set(fake_tests) + if not has_crashed: + assert runner.marionette.check_for_crash.call_count == len(fake_tests) + _check_crash_counts(has_crashed, runner, runner.marionette) + + +@pytest.mark.parametrize("has_crashed", [True, False]) +def test_record_crash(runner, has_crashed, mock_marionette): + with patch.object(runner, "marionette", mock_marionette): + assert runner.record_crash() == has_crashed + _check_crash_counts(has_crashed, runner, runner.marionette) + + +def test_add_test_module(runner): + tests = ["test_something.py", "testSomething.js", "bad_test.py"] + assert len(runner.tests) == 0 + for test in tests: + with patch("os.path.abspath", return_value=test) as abspath: + runner.add_test(test) + assert abspath.called + expected = {"filepath": test, "expected": "pass", "group": "default"} + assert expected in runner.tests + # add_test doesn't validate module names; 'bad_test.py' gets through + assert len(runner.tests) == 3 + + +def test_add_test_directory(runner): + test_dir = "path/to/tests" + dir_contents = [ + (test_dir, ("subdir",), ("test_a.py", "bad_test_a.py")), + (test_dir + "/subdir", (), ("test_b.py", "bad_test_b.py")), + ] + tests = list(dir_contents[0][2] + dir_contents[1][2]) + assert len(runner.tests) == 0 + # Need to use side effect to make isdir return True for test_dir and False for tests + with patch("os.path.isdir", side_effect=[True] + [False for t in tests]) as isdir: + with patch("os.walk", return_value=dir_contents) as walk: + runner.add_test(test_dir) + assert isdir.called and walk.called + for test in runner.tests: + assert os.path.normpath(test_dir) in test["filepath"] + assert len(runner.tests) == 2 + + +@pytest.mark.parametrize("test_files_exist", [True, False]) +def test_add_test_manifest( + mock_runner, manifest_with_tests, monkeypatch, test_files_exist +): + monkeypatch.setattr( + "marionette_harness.runner.base.TestManifest", + manifest_with_tests.manifest_class, + ) + mock_runner.marionette = mock_runner.driverclass() + with patch( + "marionette_harness.runner.base.os.path.exists", return_value=test_files_exist + ): + if test_files_exist or manifest_with_tests.n_enabled == 0: + mock_runner.add_test(manifest_with_tests.filepath) + assert len(mock_runner.tests) == manifest_with_tests.n_enabled + assert ( + len(mock_runner.manifest_skipped_tests) + == manifest_with_tests.n_disabled + ) + for test in mock_runner.tests: + assert test["filepath"].endswith(test["expected"] + ".py") + else: + with pytest.raises(IOError): + mock_runner.add_test(manifest_with_tests.filepath) + + assert manifest_with_tests.manifest_class().read.called + assert manifest_with_tests.manifest_class().active_tests.called + + +def get_kwargs_passed_to_manifest(mock_runner, manifest, monkeypatch, **kwargs): + """Helper function for test_manifest_* tests. + Returns the kwargs passed to the call to manifest.active_tests.""" + monkeypatch.setattr( + "marionette_harness.runner.base.TestManifest", manifest.manifest_class + ) + monkeypatch.setitem(mozinfo.info, "mozinfo_key", "mozinfo_val") + for attr in kwargs: + setattr(mock_runner, attr, kwargs[attr]) + mock_runner.marionette = mock_runner.driverclass() + with patch("marionette_harness.runner.base.os.path.exists", return_value=True): + mock_runner.add_test(manifest.filepath) + call_args, call_kwargs = manifest.manifest_class().active_tests.call_args + return call_kwargs + + +def test_manifest_basic_args(mock_runner, manifest, monkeypatch): + kwargs = get_kwargs_passed_to_manifest(mock_runner, manifest, monkeypatch) + assert kwargs["exists"] is False + assert kwargs["disabled"] is True + assert kwargs["appname"] == "fake_app" + assert "mozinfo_key" in kwargs and kwargs["mozinfo_key"] == "mozinfo_val" + + +@pytest.mark.parametrize("test_tags", (None, ["tag", "tag2"])) +def test_manifest_with_test_tags(mock_runner, manifest, monkeypatch, test_tags): + kwargs = get_kwargs_passed_to_manifest( + mock_runner, manifest, monkeypatch, test_tags=test_tags + ) + if test_tags is None: + assert kwargs["filters"] == [] + else: + assert len(kwargs["filters"]) == 1 and kwargs["filters"][0].tags == test_tags + + +def test_cleanup_with_manifest(mock_runner, manifest_with_tests, monkeypatch): + monkeypatch.setattr( + "marionette_harness.runner.base.TestManifest", + manifest_with_tests.manifest_class, + ) + if manifest_with_tests.n_enabled > 0: + context = patch( + "marionette_harness.runner.base.os.path.exists", return_value=True + ) + else: + context = pytest.raises(Exception) + with context: + mock_runner.run_tests([manifest_with_tests.filepath]) + assert mock_runner.marionette is None + assert mock_runner.fixture_servers == {} + + +def test_reset_test_stats(mock_runner): + def reset_successful(runner): + stats = [ + "passed", + "failed", + "unexpected_successes", + "todo", + "skipped", + "failures", + ] + return all([((s in vars(runner)) and (not vars(runner)[s])) for s in stats]) + + assert reset_successful(mock_runner) + mock_runner.passed = 1 + mock_runner.failed = 1 + mock_runner.failures.append(["TEST-UNEXPECTED-FAIL"]) + assert not reset_successful(mock_runner) + mock_runner.run_tests(["test_fake_thing.py"]) + assert reset_successful(mock_runner) + + +def test_initialize_test_run(mock_runner): + tests = ["test_fake_thing.py"] + mock_runner.reset_test_stats = Mock() + mock_runner.run_tests(tests) + assert mock_runner.reset_test_stats.called + with pytest.raises(AssertionError) as test_exc: + mock_runner.run_tests([]) + assert "len(tests)" in str(test_exc.traceback[-1].statement) + with pytest.raises(AssertionError) as hndl_exc: + mock_runner.test_handlers = [] + mock_runner.run_tests(tests) + assert "test_handlers" in str(hndl_exc.traceback[-1].statement) + assert mock_runner.reset_test_stats.call_count == 1 + + +def test_add_tests(mock_runner): + assert len(mock_runner.tests) == 0 + fake_tests = ["test_" + i + ".py" for i in "abc"] + mock_runner.run_tests(fake_tests) + assert len(mock_runner.tests) == 3 + for test_name, added_test in zip(fake_tests, mock_runner.tests): + assert added_test["filepath"].endswith(test_name) + + +def test_repeat(mock_runner): + def update_result(test, expected): + mock_runner.failed += 1 + + fake_tests = ["test_1.py"] + mock_runner.repeat = 4 + mock_runner.run_test = Mock(side_effect=update_result) + mock_runner.run_tests(fake_tests) + + assert mock_runner.failed == 5 + assert mock_runner.passed == 0 + assert mock_runner.todo == 0 + + +def test_run_until_failure(mock_runner): + def update_result(test, expected): + mock_runner.failed += 1 + + fake_tests = ["test_1.py"] + mock_runner.run_until_failure = True + mock_runner.repeat = 4 + mock_runner.run_test = Mock(side_effect=update_result) + mock_runner.run_tests(fake_tests) + + assert mock_runner.failed == 1 + assert mock_runner.passed == 0 + assert mock_runner.todo == 0 + + +def test_catch_invalid_test_names(runner): + good_tests = ["test_ok.py", "test_is_ok.py"] + bad_tests = [ + "bad_test.py", + "testbad.py", + "_test_bad.py", + "test_bad.notpy", + "test_bad", + "test.py", + "test_.py", + ] + with pytest.raises(Exception) as exc: + runner._add_tests(good_tests + bad_tests) + msg = str(exc.value) + assert "Test file names must be of the form" in msg + for bad_name in bad_tests: + assert bad_name in msg + for good_name in good_tests: + assert good_name not in msg + + +@pytest.mark.parametrize("repeat", (None, 0, 42, -1)) +def test_option_repeat(mach_parsed_kwargs, repeat): + if repeat is not None: + mach_parsed_kwargs["repeat"] = repeat + runner = MarionetteTestRunner(**mach_parsed_kwargs) + + if repeat is None: + assert runner.repeat == 0 + else: + assert runner.repeat == repeat + + +@pytest.mark.parametrize("repeat", (None, 42)) +@pytest.mark.parametrize("run_until_failure", (None, True)) +def test_option_run_until_failure(mach_parsed_kwargs, repeat, run_until_failure): + if run_until_failure is not None: + mach_parsed_kwargs["run_until_failure"] = run_until_failure + if repeat is not None: + mach_parsed_kwargs["repeat"] = repeat + runner = MarionetteTestRunner(**mach_parsed_kwargs) + + if run_until_failure is None: + assert runner.run_until_failure is False + if repeat is None: + assert runner.repeat == 0 + else: + assert runner.repeat == repeat + + else: + assert runner.run_until_failure == run_until_failure + if repeat is None: + assert runner.repeat == 30 + else: + assert runner.repeat == repeat + + +if __name__ == "__main__": + mozunit.main("-p", "no:terminalreporter", "--log-tbpl=-", "--capture", "no") diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_test_result.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_test_result.py new file mode 100644 index 0000000000..6269b4135e --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_test_result.py @@ -0,0 +1,55 @@ +# 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 mozunit +import pytest + +from marionette_harness import MarionetteTestResult + + +@pytest.fixture +def empty_marionette_testcase(): + """Testable MarionetteTestCase class""" + from marionette_harness import MarionetteTestCase + + class EmptyTestCase(MarionetteTestCase): + def test_nothing(self): + pass + + return EmptyTestCase + + +@pytest.fixture +def empty_marionette_test(mock_marionette, empty_marionette_testcase): + return empty_marionette_testcase( + lambda: mock_marionette, lambda: mock_httpd, "test_nothing" + ) + + +@pytest.mark.parametrize("has_crashed", [True, False]) +def test_crash_is_recorded_as_error(empty_marionette_test, logger, has_crashed): + """Number of errors is incremented by stopTest iff has_crashed is true""" + # collect results from the empty test + result = MarionetteTestResult( + marionette=empty_marionette_test._marionette_weakref(), + logger=logger, + verbosity=1, + stream=None, + descriptions=None, + ) + result.startTest(empty_marionette_test) + assert len(result.errors) == 0 + assert len(result.failures) == 0 + assert result.testsRun == 1 + assert result.shouldStop is False + result.stopTest(empty_marionette_test) + assert result.shouldStop == has_crashed + if has_crashed: + assert len(result.errors) == 1 + else: + assert len(result.errors) == 0 + + +if __name__ == "__main__": + mozunit.main("-p", "no:terminalreporter", "--log-tbpl=-", "--capture", "no") diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/test_serve.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_serve.py new file mode 100644 index 0000000000..84e1f7ddf4 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_serve.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/. + +import types + +import six + +import mozunit +import pytest + +from marionette_harness.runner import serve +from marionette_harness.runner.serve import iter_proc, iter_url + + +def teardown_function(func): + for server in [s for s in iter_proc(serve.servers) if s.is_alive]: + server.stop() + server.kill() + + +def test_registered_servers(): + # [(name, factory), ...] + assert serve.registered_servers[0][0] == "http" + assert serve.registered_servers[1][0] == "https" + + +def test_globals(): + assert serve.default_doc_root is not None + assert serve.registered_servers is not None + assert serve.servers is not None + + +def test_start(): + serve.start() + assert len(serve.servers) == 2 + assert "http" in serve.servers + assert "https" in serve.servers + for url in iter_url(serve.servers): + assert isinstance(url, six.string_types) + + +def test_start_with_custom_root(tmpdir_factory): + tdir = tmpdir_factory.mktemp("foo") + serve.start(str(tdir)) + for server in iter_proc(serve.servers): + assert server.doc_root == tdir + + +def test_iter_proc(): + serve.start() + for server in iter_proc(serve.servers): + server.stop() + + +def test_iter_url(): + serve.start() + for url in iter_url(serve.servers): + assert isinstance(url, six.string_types) + + +def test_where_is(): + serve.start() + assert serve.where_is("/") == serve.servers["http"][1].get_url("/") + assert serve.where_is("/", on="https") == serve.servers["https"][1].get_url("/") + + +if __name__ == "__main__": + mozunit.main("-p", "no:terminalreporter", "--log-tbpl=-", "--capture", "no") diff --git a/testing/marionette/harness/marionette_harness/tests/unit-tests.toml b/testing/marionette/harness/marionette_harness/tests/unit-tests.toml new file mode 100644 index 0000000000..26f6f559f0 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit-tests.toml @@ -0,0 +1,43 @@ +# The tests within this file are exclusively executed when `mach marionette-test` +# is called without specifying a test path. In case a specific test or manifest +# is provided, only that particular test or manifest is executed. Alternatively, +# by using a path prefix, any manifest file is recursively searched for under +# the specified path. +# +# Note: When adding a new top-level manifest file please also add a reference +# to the `MARIONETTE_MANIFESTS` entry in the appropriate `moz.build` file to +# allow the execution of tests via `mach test` and as part of the test package +# as well. + +[DEFAULT] +# marionette unit tests +["include:unit/unit-tests.toml"] + +# DOM tests +["include:../../../../../dom/cache/test/marionette/manifest.toml"] +["include:../../../../../dom/indexedDB/test/marionette/manifest.toml"] +["include:../../../../../dom/quota/test/marionette/manifest.toml"] +["include:../../../../../dom/workers/test/marionette/manifest.toml"] + +# browser tests +["include:../../../../../browser/components/tests/marionette/manifest.toml"] +["include:../../../../../browser/components/migration/tests/marionette/manifest.toml"] +["include:../../../../../browser/components/places/tests/marionette/manifest.toml"] +["include:../../../../../browser/components/search/test/marionette/manifest.toml"] +["include:../../../../../browser/components/sessionstore/test/marionette/manifest.toml"] + +# extensions tests +["include:../../../../../extensions/pref/autoconfig/test/marionette/manifest.toml"] + +# layout tests +["include:../../../../../layout/base/tests/marionette/manifest.toml"] + +# netwerk tests +["include:../../../../../netwerk/test/marionette/manifest.toml"] + +# toolkit tests +["include:../../../../../toolkit/components/cleardata/tests/marionette/manifest.toml"] +["include:../../../../../toolkit/xre/test/marionette/marionette.toml"] + +# update tests +["include:../../../../../toolkit/mozapps/update/tests/marionette/marionette.toml"] diff --git a/testing/marionette/harness/marionette_harness/tests/unit/data/test.html b/testing/marionette/harness/marionette_harness/tests/unit/data/test.html new file mode 100644 index 0000000000..8334cf0a2e --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/data/test.html @@ -0,0 +1,13 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html> +<head> +<title>Marionette Test</title> +</head> +<body> + <p id="file-url">Loaded via file://</p> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_accessibility.py b/testing/marionette/harness/marionette_harness/tests/unit/test_accessibility.py new file mode 100644 index 0000000000..112a6974d1 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_accessibility.py @@ -0,0 +1,241 @@ +# 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 sys +import unittest + +from marionette_driver.by import By +from marionette_driver.errors import ( + ElementNotAccessibleException, + ElementNotInteractableException, + ElementClickInterceptedException, +) + +from marionette_harness import MarionetteTestCase + + +class TestAccessibility(MarionetteTestCase): + def setUp(self): + super(TestAccessibility, self).setUp() + with self.marionette.using_context("chrome"): + self.marionette.set_pref("dom.ipc.processCount", 1) + + def tearDown(self): + with self.marionette.using_context("chrome"): + self.marionette.clear_pref("dom.ipc.processCount") + + # Elements that are accessible with and without the accessibliity API + valid_elementIDs = [ + # Button1 is an accessible button with a valid accessible name + # computed from subtree + "button1", + # Button2 is an accessible button with a valid accessible name + # computed from aria-label + "button2", + # Button13 is an accessible button that is implemented via role="button" + # and is explorable using tabindex="0" + "button13", + # button17 is an accessible button that overrides parent's + # pointer-events:none; property with its own pointer-events:all; + "button17", + ] + + # Elements that are not accessible with the accessibility API + invalid_elementIDs = [ + # Button3 does not have an accessible object + "button3", + # Button4 does not support any accessible actions + "button4", + # Button5 does not have a correct accessibility role and may not be + # manipulated via the accessibility API + "button5", + # Button6 is missing an accessible name + "button6", + # Button7 is not currently visible via the accessibility API and may + # not be manipulated by it + "button7", + # Button8 is not currently visible via the accessibility API and may + # not be manipulated by it (in hidden subtree) + "button8", + # Button14 is accessible button but is not explorable because of lack + # of tabindex that would make it focusable. + "button14", + ] + + # Elements that are either accessible to accessibility API or not accessible + # at all + falsy_elements = [ + # Element is only visible to the accessibility API and may be + # manipulated by it + "button9", + # Element is not currently visible + "button10", + ] + + displayed_elementIDs = ["button1", "button2", "button4", "button5", "button6"] + + displayed_but_have_no_accessible_elementIDs = [ + # Button3 does not have an accessible object + "button3", + # Button 7 is hidden with aria-hidden set to true + "button7", + # Button 8 is inside an element with aria-hidden set to true + "button8", + "no_accessible_but_displayed", + ] + + disabled_elementIDs = ["button11", "no_accessible_but_disabled"] + + # Elements that are enabled but otherwise disabled or not explorable + # via the accessibility API + aria_disabled_elementIDs = ["button12"] + + # pointer-events: "none", which will return + # ElementClickInterceptedException if clicked + # when Marionette switches + # to using WebDriver conforming interaction + pointer_events_none_elementIDs = ["button15", "button16"] + + # Elements that are reporting selected state + valid_option_elementIDs = ["option1", "option2"] + + def run_element_test(self, ids, testFn): + for id in ids: + element = self.marionette.find_element(By.ID, id) + testFn(element) + + def setup_accessibility(self, enable_a11y_checks=True, navigate=True): + self.marionette.delete_session() + self.marionette.start_session({"moz:accessibilityChecks": enable_a11y_checks}) + self.assertEqual( + self.marionette.session_capabilities["moz:accessibilityChecks"], + enable_a11y_checks, + ) + + # Navigate to test_accessibility.html + if navigate: + test_accessibility = self.marionette.absolute_url("test_accessibility.html") + self.marionette.navigate(test_accessibility) + + def test_valid_click(self): + self.setup_accessibility() + # No exception should be raised + self.run_element_test(self.valid_elementIDs, lambda button: button.click()) + + def test_click_raises_element_not_accessible(self): + self.setup_accessibility() + self.run_element_test( + self.invalid_elementIDs, + lambda button: self.assertRaises( + ElementNotAccessibleException, button.click + ), + ) + self.run_element_test( + self.falsy_elements, + lambda button: self.assertRaises( + ElementNotInteractableException, button.click + ), + ) + + def test_click_raises_no_exceptions(self): + self.setup_accessibility(False, True) + # No exception should be raised + self.run_element_test(self.invalid_elementIDs, lambda button: button.click()) + # Elements are invisible + self.run_element_test( + self.falsy_elements, + lambda button: self.assertRaises( + ElementNotInteractableException, button.click + ), + ) + + def test_element_visible_but_not_visible_to_accessbility(self): + self.setup_accessibility() + # Elements are displayed but hidden from accessibility API + self.run_element_test( + self.displayed_but_have_no_accessible_elementIDs, + lambda element: self.assertRaises( + ElementNotAccessibleException, element.is_displayed + ), + ) + + def test_element_is_visible_to_accessibility(self): + self.setup_accessibility() + # No exception should be raised + self.run_element_test( + self.displayed_elementIDs, lambda element: element.is_displayed() + ) + + def test_element_is_not_enabled_to_accessbility(self): + self.setup_accessibility() + # Buttons are enabled but disabled/not-explorable via the accessibility API + self.run_element_test( + self.aria_disabled_elementIDs, + lambda element: self.assertRaises( + ElementNotAccessibleException, element.is_enabled + ), + ) + self.run_element_test( + self.pointer_events_none_elementIDs, + lambda element: self.assertRaises( + ElementNotAccessibleException, element.is_enabled + ), + ) + + # Buttons are enabled but disabled/not-explorable via + # the accessibility API and thus are not clickable via the + # accessibility API. + self.run_element_test( + self.aria_disabled_elementIDs, + lambda element: self.assertRaises( + ElementNotAccessibleException, element.click + ), + ) + # To be removed with bug 1405967 + if not self.marionette.session_capabilities["moz:webdriverClick"]: + self.run_element_test( + self.pointer_events_none_elementIDs, + lambda element: self.assertRaises( + ElementNotAccessibleException, element.click + ), + ) + + self.setup_accessibility(False, False) + self.run_element_test( + self.aria_disabled_elementIDs, lambda element: element.is_enabled() + ) + self.run_element_test( + self.pointer_events_none_elementIDs, lambda element: element.is_enabled() + ) + self.run_element_test( + self.aria_disabled_elementIDs, lambda element: element.click() + ) + # To be removed with bug 1405967 + if not self.marionette.session_capabilities["moz:webdriverClick"]: + self.run_element_test( + self.pointer_events_none_elementIDs, lambda element: element.click() + ) + + def test_element_is_enabled_to_accessibility(self): + self.setup_accessibility() + # No exception should be raised + self.run_element_test( + self.disabled_elementIDs, lambda element: element.is_enabled() + ) + + def test_send_keys_raises_no_exception(self): + self.setup_accessibility() + # Sending keys to valid input should not raise any exceptions + self.run_element_test(["input1"], lambda element: element.send_keys("a")) + + def test_is_selected_raises_no_exception(self): + self.setup_accessibility() + # No exception should be raised for valid options + self.run_element_test( + self.valid_option_elementIDs, lambda element: element.is_selected() + ) + # No exception should be raised for non-selectable elements + self.run_element_test( + self.valid_elementIDs, lambda element: element.is_selected() + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_actions_key.py b/testing/marionette/harness/marionette_harness/tests/unit/test_actions_key.py new file mode 100644 index 0000000000..9f28b8eb4f --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_actions_key.py @@ -0,0 +1,71 @@ +# 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 six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_driver.keys import Keys +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestKeyActions(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestKeyActions, self).setUp() + self.key_chain = self.marionette.actions.sequence("key", "keyboard_id") + + if self.marionette.session_capabilities["platformName"] == "mac": + self.mod_key = Keys.META + else: + self.mod_key = Keys.CONTROL + + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + self.reporter_element = self.marionette.find_element(By.ID, "keyReporter") + self.reporter_element.click() + + def tearDown(self): + self.marionette.actions.release() + + super(TestKeyActions, self).tearDown() + + @property + def key_reporter_value(self): + return self.reporter_element.get_property("value") + + def test_basic_input(self): + self.key_chain.key_down("a").key_down("b").key_down("c").perform() + self.assertEqual(self.key_reporter_value, "abc") + + def test_upcase_input(self): + self.key_chain.key_down(Keys.SHIFT).key_down("a").key_up(Keys.SHIFT).key_down( + "b" + ).key_down("c").perform() + self.assertEqual(self.key_reporter_value, "Abc") + + def test_replace_input(self): + self.key_chain.key_down("a").key_down("b").key_down("c").perform() + self.assertEqual(self.key_reporter_value, "abc") + + self.key_chain.key_down(self.mod_key).key_down("a").key_up( + self.mod_key + ).key_down("x").perform() + self.assertEqual(self.key_reporter_value, "x") + + def test_clear_input(self): + self.key_chain.key_down("a").key_down("b").key_down("c").perform() + self.assertEqual(self.key_reporter_value, "abc") + + self.key_chain.key_down(self.mod_key).key_down("a").key_down("x").perform() + self.assertEqual(self.key_reporter_value, "") + + def test_input_with_wait(self): + self.key_chain.key_down("a").key_down("b").key_down("c").perform() + self.key_chain.key_down(self.mod_key).key_down("a").pause(250).key_down( + "x" + ).perform() + self.assertEqual(self.key_reporter_value, "") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_actions_pointer.py b/testing/marionette/harness/marionette_harness/tests/unit/test_actions_pointer.py new file mode 100644 index 0000000000..1e21316c52 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_actions_pointer.py @@ -0,0 +1,134 @@ +# 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 six.moves.urllib.parse import quote + +from marionette_driver import By, errors, Wait +from marionette_driver.keys import Keys + +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class BaseMouseAction(MarionetteTestCase): + def setUp(self): + super(BaseMouseAction, self).setUp() + self.mouse_chain = self.marionette.actions.sequence( + "pointer", "pointer_id", {"pointerType": "mouse"} + ) + + if self.marionette.session_capabilities["platformName"] == "mac": + self.mod_key = Keys.META + else: + self.mod_key = Keys.CONTROL + + def tearDown(self): + self.marionette.actions.release() + + super(BaseMouseAction, self).tearDown() + + @property + def click_position(self): + return self.marionette.execute_script( + """ + if (window.click_x && window.click_y) { + return {x: window.click_x, y: window.click_y}; + } + """, + sandbox=None, + ) + + def get_element_center_point(self, elem): + # pylint --py3k W1619 + return { + "x": elem.rect["x"] + elem.rect["width"] / 2, + "y": elem.rect["y"] + elem.rect["height"] / 2, + } + + +class TestPointerActions(BaseMouseAction): + def test_click_action(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + link = self.marionette.find_element(By.ID, "mozLink") + self.mouse_chain.click(element=link).perform() + self.assertEqual( + "Clicked", + self.marionette.execute_script( + "return document.getElementById('mozLink').innerHTML" + ), + ) + + def test_clicking_element_out_of_view(self): + self.marionette.navigate( + inline( + """ + <div style="position:relative;top:200vh;">foo</div> + """ + ) + ) + el = self.marionette.find_element(By.TAG_NAME, "div") + with self.assertRaises(errors.MoveTargetOutOfBoundsException): + self.mouse_chain.click(element=el).perform() + + def test_double_click_action(self): + self.marionette.navigate( + inline( + """ + <script>window.eventCount = 0;</script> + <button onclick="window.eventCount++">foobar</button> + """ + ) + ) + + el = self.marionette.find_element(By.CSS_SELECTOR, "button") + self.mouse_chain.click(el).pause(100).click(el).perform() + + event_count = self.marionette.execute_script( + "return window.eventCount", sandbox=None + ) + self.assertEqual(event_count, 2) + + def test_context_click_action(self): + test_html = self.marionette.absolute_url("clicks.html") + self.marionette.navigate(test_html) + click_el = self.marionette.find_element(By.ID, "normal") + + def context_menu_state(): + with self.marionette.using_context("chrome"): + cm_el = self.marionette.find_element(By.ID, "contentAreaContextMenu") + return cm_el.get_property("state") + + self.assertEqual("closed", context_menu_state()) + self.mouse_chain.click(element=click_el, button=2).perform() + Wait(self.marionette).until( + lambda _: context_menu_state() == "open", + message="Context menu did not open", + ) + with self.marionette.using_context("chrome"): + cm_el = self.marionette.find_element(By.ID, "contentAreaContextMenu") + self.marionette.execute_script( + "arguments[0].hidePopup()", script_args=(cm_el,) + ) + Wait(self.marionette).until( + lambda _: context_menu_state() == "closed", + message="Context menu did not close", + ) + + def test_middle_click_action(self): + test_html = self.marionette.absolute_url("clicks.html") + self.marionette.navigate(test_html) + + self.marionette.find_element(By.ID, "addbuttonlistener").click() + + el = self.marionette.find_element(By.ID, "showbutton") + self.mouse_chain.click(element=el, button=1).perform() + + Wait(self.marionette).until( + lambda _: el.get_property("innerHTML") == "1", + message="Middle-click hasn't been fired", + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_actions_wheel.py b/testing/marionette/harness/marionette_harness/tests/unit/test_actions_wheel.py new file mode 100644 index 0000000000..e74d9f6423 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_actions_wheel.py @@ -0,0 +1,68 @@ +# 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_driver import By +from marionette_harness import MarionetteTestCase, parameterized + + +class BaseWheelAction(MarionetteTestCase): + def setUp(self): + super(BaseWheelAction, self).setUp() + + self.test_page = self.marionette.absolute_url("actions_scroll.html") + self.marionette.navigate(self.test_page) + + self.wheel_chain = self.marionette.actions.sequence("wheel", "wheel_id") + + def tearDown(self): + self.marionette.actions.release() + + super(BaseWheelAction, self).tearDown() + + def get_events(self): + return self.marionette.execute_script("return allEvents.events;", sandbox=None) + + +class TestWheelAction(BaseWheelAction): + def test_scroll_not_scrollable(self): + target = self.marionette.find_element(By.ID, "not-scrollable") + + self.wheel_chain.scroll(0, 0, 5, 10, origin=target, duration=0).perform() + + events = self.get_events() + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["type"], "wheel") + self.assertEqual(events[0]["deltaX"], 5) + self.assertEqual(events[0]["deltaY"], 10) + self.assertEqual(events[0]["deltaZ"], 0) + self.assertEqual(events[0]["target"], "not-scrollable-content") + + def test_scroll_scrollable(self): + target = self.marionette.find_element(By.ID, "scrollable") + self.wheel_chain.scroll(0, 0, 5, 10, origin=target).perform() + + events = self.get_events() + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["type"], "wheel") + self.assertEqual(events[0]["deltaX"], 5) + self.assertEqual(events[0]["deltaY"], 10) + self.assertEqual(events[0]["deltaZ"], 0) + self.assertEqual(events[0]["target"], "scrollable-content") + + def test_scroll_iframe_scrollable(self): + iframe = self.marionette.find_element(By.ID, "iframe") + self.marionette.switch_to_frame(iframe) + + target = self.marionette.find_element(By.ID, "iframeContent") + self.wheel_chain.scroll(0, 0, 5, 10, origin=target).perform() + + self.marionette.switch_to_frame() + + events = self.get_events() + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["type"], "wheel") + self.assertEqual(events[0]["deltaX"], 5) + self.assertEqual(events[0]["deltaY"], 10) + self.assertEqual(events[0]["deltaZ"], 0) + self.assertEqual(events[0]["target"], "iframeContent") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_addons.py b/testing/marionette/harness/marionette_harness/tests/unit/test_addons.py new file mode 100644 index 0000000000..1611739e5f --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_addons.py @@ -0,0 +1,140 @@ +# 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 +from unittest import skipIf + +from marionette_driver.addons import Addons, AddonInstallException +from marionette_driver.errors import UnknownException +from marionette_harness import MarionetteTestCase + + +here = os.path.abspath(os.path.dirname(__file__)) + + +class TestAddons(MarionetteTestCase): + def setUp(self): + super(TestAddons, self).setUp() + + self.addons = Addons(self.marionette) + self.preinstalled_addons = self.all_addon_ids + + def tearDown(self): + self.reset_addons() + + super(TestAddons, self).tearDown() + + @property + def all_addon_ids(self): + with self.marionette.using_context("chrome"): + addons = self.marionette.execute_async_script( + """ + const [resolve] = arguments; + const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" + ); + + async function getAllAddons() { + const addons = await AddonManager.getAllAddons(); + const ids = addons.map(x => x.id); + resolve(ids); + } + + getAllAddons(); + """ + ) + + return set(addons) + + def reset_addons(self): + with self.marionette.using_context("chrome"): + for addon in self.all_addon_ids - self.preinstalled_addons: + addon_id = self.marionette.execute_async_script( + """ + const [addonId, resolve] = arguments; + const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" + ); + + async function uninstall() { + const addon = await AddonManager.getAddonByID(addonId); + addon.uninstall(); + resolve(addon.id); + } + + uninstall(); + """, + script_args=(addon,), + ) + self.assertEqual( + addon_id, addon, msg="Failed to uninstall {}".format(addon) + ) + + def test_temporary_install_and_remove_unsigned_addon(self): + addon_path = os.path.join(here, "webextension-unsigned.xpi") + + addon_id = self.addons.install(addon_path, temp=True) + self.assertIn(addon_id, self.all_addon_ids) + self.assertEqual(addon_id, "{d3e7c1f1-2e35-4a49-89fe-9f46eb8abf0a}") + + self.addons.uninstall(addon_id) + self.assertNotIn(addon_id, self.all_addon_ids) + + def test_temporary_install_invalid_addon(self): + addon_path = os.path.join(here, "webextension-invalid.xpi") + + with self.assertRaises(AddonInstallException): + self.addons.install(addon_path, temp=True) + self.assertNotIn("{d3e7c1f1-2e35-4a49-89fe-9f46eb8abf0a}", self.all_addon_ids) + + def test_install_and_remove_signed_addon(self): + addon_path = os.path.join(here, "webextension-signed.xpi") + + addon_id = self.addons.install(addon_path) + self.assertIn(addon_id, self.all_addon_ids) + self.assertEqual(addon_id, "{d3e7c1f1-2e35-4a49-89fe-9f46eb8abf0a}") + + self.addons.uninstall(addon_id) + self.assertNotIn(addon_id, self.all_addon_ids) + + def test_install_invalid_addon(self): + addon_path = os.path.join(here, "webextension-invalid.xpi") + + with self.assertRaises(AddonInstallException): + self.addons.install(addon_path) + self.assertNotIn("{d3e7c1f1-2e35-4a49-89fe-9f46eb8abf0a}", self.all_addon_ids) + + def test_install_unsigned_addon_fails(self): + addon_path = os.path.join(here, "webextension-unsigned.xpi") + + with self.assertRaises(AddonInstallException): + self.addons.install(addon_path) + + def test_install_nonexistent_addon(self): + addon_path = os.path.join(here, "does-not-exist.xpi") + + with self.assertRaises(AddonInstallException): + self.addons.install(addon_path) + + def test_install_with_relative_path(self): + with self.assertRaises(AddonInstallException): + self.addons.install("webextension.xpi") + + @skipIf(sys.platform != "win32", "Only makes sense on Windows") + def test_install_mixed_separator_windows(self): + # Ensure the base path has only \ + addon_path = here.replace("/", "\\") + addon_path += "/webextension-signed.xpi" + + addon_id = self.addons.install(addon_path, temp=True) + self.assertIn(addon_id, self.all_addon_ids) + self.assertEqual(addon_id, "{d3e7c1f1-2e35-4a49-89fe-9f46eb8abf0a}") + + self.addons.uninstall(addon_id) + self.assertNotIn(addon_id, self.all_addon_ids) + + def test_uninstall_nonexistent_addon(self): + with self.assertRaises(UnknownException): + self.addons.uninstall("i-do-not-exist-as-an-id") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_capabilities.py b/testing/marionette/harness/marionette_harness/tests/unit/test_capabilities.py new file mode 100644 index 0000000000..0cdaf8343f --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_capabilities.py @@ -0,0 +1,322 @@ +# 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 unittest + +import marionette_driver.errors as errors +from marionette_harness import MarionetteTestCase + + +class TestCapabilities(MarionetteTestCase): + def setUp(self): + super(TestCapabilities, self).setUp() + self.caps = self.marionette.session_capabilities + with self.marionette.using_context("chrome"): + self.appinfo = self.marionette.execute_script( + """ + return { + name: Services.appinfo.name, + version: Services.appinfo.version, + processID: Services.appinfo.processID, + buildID: Services.appinfo.appBuildID, + } + """ + ) + self.os_name = self.marionette.execute_script( + """ + let name = Services.sysinfo.getProperty("name"); + switch (name) { + case "Windows_NT": + return "windows"; + case "Darwin": + return "mac"; + default: + return name.toLowerCase(); + } + """ + ) + self.os_version = self.marionette.execute_script( + "return Services.sysinfo.getProperty('version')" + ) + + def test_mandated_capabilities(self): + self.assertIn("acceptInsecureCerts", self.caps) + self.assertIn("browserName", self.caps) + self.assertIn("browserVersion", self.caps) + self.assertIn("platformName", self.caps) + self.assertIn("proxy", self.caps) + self.assertIn("setWindowRect", self.caps) + self.assertIn("strictFileInteractability", self.caps) + self.assertIn("timeouts", self.caps) + + self.assertFalse(self.caps["acceptInsecureCerts"]) + self.assertEqual(self.caps["browserName"], self.appinfo["name"].lower()) + self.assertEqual(self.caps["browserVersion"], self.appinfo["version"]) + self.assertEqual(self.caps["platformName"], self.os_name) + self.assertEqual(self.caps["proxy"], {}) + + if self.appinfo["name"] == "Firefox": + self.assertTrue(self.caps["setWindowRect"]) + else: + self.assertFalse(self.caps["setWindowRect"]) + self.assertTrue(self.caps["strictFileInteractability"]) + self.assertDictEqual( + self.caps["timeouts"], {"implicit": 0, "pageLoad": 300000, "script": 30000} + ) + + def test_additional_capabilities(self): + self.assertIn("moz:processID", self.caps) + self.assertEqual(self.caps["moz:processID"], self.appinfo["processID"]) + self.assertEqual(self.marionette.process_id, self.appinfo["processID"]) + + self.assertIn("moz:profile", self.caps) + if self.marionette.instance is not None: + if self.caps["browserName"] == "fennec": + current_profile = ( + self.marionette.instance.runner.device.app_ctx.remote_profile + ) + else: + current_profile = self.marionette.profile_path + # Bug 1438461 - mozprofile uses lower-case letters even on case-sensitive filesystems + # Bug 1533221 - paths may differ due to file system links or aliases + self.assertEqual( + os.path.basename(self.caps["moz:profile"]).lower(), + os.path.basename(current_profile).lower(), + ) + + self.assertIn("moz:accessibilityChecks", self.caps) + self.assertFalse(self.caps["moz:accessibilityChecks"]) + + self.assertIn("moz:buildID", self.caps) + self.assertEqual(self.caps["moz:buildID"], self.appinfo["buildID"]) + + self.assertNotIn("moz:debuggerAddress", self.caps) + + self.assertIn("moz:platformVersion", self.caps) + self.assertEqual(self.caps["moz:platformVersion"], self.os_version) + + self.assertIn("moz:webdriverClick", self.caps) + self.assertTrue(self.caps["moz:webdriverClick"]) + + self.assertIn("moz:windowless", self.caps) + self.assertFalse(self.caps["moz:windowless"]) + + # No longer supported capabilities + self.assertNotIn("moz:useNonSpecCompliantPointerOrigin", self.caps) + + def test_disable_webdriver_click(self): + self.marionette.delete_session() + self.marionette.start_session({"moz:webdriverClick": False}) + caps = self.marionette.session_capabilities + self.assertFalse(caps["moz:webdriverClick"]) + + def test_no_longer_supported_capabilities(self): + self.marionette.delete_session() + with self.assertRaisesRegexp( + errors.SessionNotCreatedException, "InvalidArgumentError" + ): + self.marionette.start_session( + {"moz:useNonSpecCompliantPointerOrigin": True} + ) + + def test_valid_uuid4_when_creating_a_session(self): + self.assertNotIn( + "{", + self.marionette.session_id, + "Session ID has {{}} in it: {}".format(self.marionette.session_id), + ) + + def test_windowless_false(self): + self.marionette.delete_session() + self.marionette.start_session({"moz:windowless": False}) + caps = self.marionette.session_capabilities + self.assertFalse(caps["moz:windowless"]) + + @unittest.skipUnless(sys.platform.startswith("darwin"), "Only supported on MacOS") + def test_windowless_true(self): + self.marionette.delete_session() + self.marionette.start_session({"moz:windowless": True}) + caps = self.marionette.session_capabilities + self.assertTrue(caps["moz:windowless"]) + + +class TestCapabilityMatching(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + self.browser_name = self.marionette.session_capabilities["browserName"] + self.delete_session() + + def delete_session(self): + if self.marionette.session is not None: + self.marionette.delete_session() + + def test_accept_insecure_certs(self): + for value in ["", 42, {}, []]: + print(" type {}".format(type(value))) + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session({"acceptInsecureCerts": value}) + + self.delete_session() + self.marionette.start_session({"acceptInsecureCerts": True}) + self.assertTrue(self.marionette.session_capabilities["acceptInsecureCerts"]) + + def test_page_load_strategy(self): + for strategy in ["none", "eager", "normal"]: + print("valid strategy {}".format(strategy)) + self.delete_session() + self.marionette.start_session({"pageLoadStrategy": strategy}) + self.assertEqual( + self.marionette.session_capabilities["pageLoadStrategy"], strategy + ) + + self.delete_session() + + for value in ["", "EAGER", True, 42, {}, []]: + print("invalid strategy {}".format(value)) + with self.assertRaisesRegexp( + errors.SessionNotCreatedException, "InvalidArgumentError" + ): + self.marionette.start_session({"pageLoadStrategy": value}) + + def test_set_window_rect(self): + with self.assertRaisesRegexp( + errors.SessionNotCreatedException, "InvalidArgumentError" + ): + self.marionette.start_session({"setWindowRect": False}) + + def test_timeouts(self): + for value in ["", 2.5, {}, []]: + print(" type {}".format(type(value))) + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session({"timeouts": {"pageLoad": value}}) + + self.delete_session() + + timeouts = {"implicit": 0, "pageLoad": 2.0, "script": 2**53 - 1} + self.marionette.start_session({"timeouts": timeouts}) + self.assertIn("timeouts", self.marionette.session_capabilities) + self.assertDictEqual(self.marionette.session_capabilities["timeouts"], timeouts) + self.assertDictEqual( + self.marionette._send_message("WebDriver:GetTimeouts"), timeouts + ) + + def test_strict_file_interactability(self): + for value in ["", 2.5, {}, []]: + print(" type {}".format(type(value))) + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session({"strictFileInteractability": value}) + + self.delete_session() + + self.marionette.start_session({"strictFileInteractability": True}) + self.assertIn("strictFileInteractability", self.marionette.session_capabilities) + self.assertTrue( + self.marionette.session_capabilities["strictFileInteractability"] + ) + + self.delete_session() + + self.marionette.start_session({"strictFileInteractability": False}) + self.assertIn("strictFileInteractability", self.marionette.session_capabilities) + self.assertFalse( + self.marionette.session_capabilities["strictFileInteractability"] + ) + + def test_unhandled_prompt_behavior(self): + behaviors = [ + "accept", + "accept and notify", + "dismiss", + "dismiss and notify", + "ignore", + ] + + for behavior in behaviors: + print("valid unhandled prompt behavior {}".format(behavior)) + self.delete_session() + self.marionette.start_session({"unhandledPromptBehavior": behavior}) + self.assertEqual( + self.marionette.session_capabilities["unhandledPromptBehavior"], + behavior, + ) + + # Default value + self.delete_session() + self.marionette.start_session() + self.assertEqual( + self.marionette.session_capabilities["unhandledPromptBehavior"], + "dismiss and notify", + ) + + # Invalid values + self.delete_session() + for behavior in ["", "ACCEPT", True, 42, {}, []]: + print("invalid unhandled prompt behavior {}".format(behavior)) + with self.assertRaisesRegexp( + errors.SessionNotCreatedException, "InvalidArgumentError" + ): + self.marionette.start_session({"unhandledPromptBehavior": behavior}) + + def test_web_socket_url(self): + self.marionette.start_session({"webSocketUrl": True}) + # Remote Agent is not active by default + self.assertNotIn("webSocketUrl", self.marionette.session_capabilities) + + def test_webauthn_extension_cred_blob(self): + for value in ["", 42, {}, []]: + print(" type {}".format(type(value))) + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session({"webauthn:extension:credBlob": value}) + + self.delete_session() + self.marionette.start_session({"webauthn:extension:credBlob": True}) + self.assertTrue( + self.marionette.session_capabilities["webauthn:extension:credBlob"] + ) + + def test_webauthn_extension_large_blob(self): + for value in ["", 42, {}, []]: + print(" type {}".format(type(value))) + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session({"webauthn:extension:largeBlob": value}) + + self.delete_session() + self.marionette.start_session({"webauthn:extension:largeBlob": True}) + self.assertTrue( + self.marionette.session_capabilities["webauthn:extension:largeBlob"] + ) + + def test_webauthn_extension_prf(self): + for value in ["", 42, {}, []]: + print(" type {}".format(type(value))) + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session({"webauthn:extension:prf": value}) + + self.delete_session() + self.marionette.start_session({"webauthn:extension:prf": True}) + self.assertTrue(self.marionette.session_capabilities["webauthn:extension:prf"]) + + def test_webauthn_extension_uvm(self): + for value in ["", 42, {}, []]: + print(" type {}".format(type(value))) + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session({"webauthn:extension:uvm": value}) + + self.delete_session() + self.marionette.start_session({"webauthn:extension:uvm": True}) + self.assertTrue(self.marionette.session_capabilities["webauthn:extension:uvm"]) + + def test_webauthn_virtual_authenticators(self): + for value in ["", 42, {}, []]: + print(" type {}".format(type(value))) + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session({"webauthn:virtualAuthenticators": value}) + + self.delete_session() + self.marionette.start_session({"webauthn:virtualAuthenticators": True}) + self.assertTrue( + self.marionette.session_capabilities["webauthn:virtualAuthenticators"] + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_checkbox.py b/testing/marionette/harness/marionette_harness/tests/unit/test_checkbox.py new file mode 100644 index 0000000000..8709d6e325 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_checkbox.py @@ -0,0 +1,17 @@ +# 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_driver.by import By + +from marionette_harness import MarionetteTestCase + + +class TestCheckbox(MarionetteTestCase): + def test_selected(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + box = self.marionette.find_element(By.NAME, "myCheckBox") + self.assertFalse(box.is_selected()) + box.click() + self.assertTrue(box.is_selected()) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_checkbox_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_checkbox_chrome.py new file mode 100644 index 0000000000..e8640d9021 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_checkbox_chrome.py @@ -0,0 +1,33 @@ +# 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_driver.by import By + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestSelectedChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestSelectedChrome, self).setUp() + + self.marionette.set_context("chrome") + + new_window = self.open_chrome_window( + "chrome://remote/content/marionette/test.xhtml" + ) + self.marionette.switch_to_window(new_window) + + def tearDown(self): + try: + self.close_all_windows() + finally: + super(TestSelectedChrome, self).tearDown() + + def test_selected(self): + box = self.marionette.find_element(By.ID, "testBox") + self.assertFalse(box.is_selected()) + self.assertFalse( + self.marionette.execute_script("arguments[0].checked = true;", [box]) + ) + self.assertTrue(box.is_selected()) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_chrome.py new file mode 100644 index 0000000000..664fbeeb37 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_chrome.py @@ -0,0 +1,31 @@ +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class ChromeTests(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(ChromeTests, self).setUp() + + def tearDown(self): + self.close_all_windows() + super(ChromeTests, self).tearDown() + + def test_hang_until_timeout(self): + with self.marionette.using_context("chrome"): + new_window = self.open_window() + self.marionette.switch_to_window(new_window) + + try: + try: + # Raise an exception type which should not be thrown by Marionette + # while running this test. Otherwise it would mask eg. IOError as + # thrown for a socket timeout. + raise NotImplementedError( + "Exception should not cause a hang when " + "closing the chrome window in content " + "context" + ) + finally: + self.marionette.close_chrome_window() + self.marionette.switch_to_window(self.start_window) + except NotImplementedError: + pass diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_chrome_action.py b/testing/marionette/harness/marionette_harness/tests/unit/test_chrome_action.py new file mode 100644 index 0000000000..fadabe9602 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_chrome_action.py @@ -0,0 +1,61 @@ +# 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_driver import By +from marionette_driver.keys import Keys + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestPointerActions(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestPointerActions, self).setUp() + + self.mouse_chain = self.marionette.actions.sequence( + "pointer", "pointer_id", {"pointerType": "mouse"} + ) + self.key_chain = self.marionette.actions.sequence("key", "keyboard_id") + + if self.marionette.session_capabilities["platformName"] == "mac": + self.mod_key = Keys.META + else: + self.mod_key = Keys.CONTROL + + self.marionette.set_context("chrome") + + self.win = self.open_chrome_window( + "chrome://remote/content/marionette/test.xhtml" + ) + self.marionette.switch_to_window(self.win) + + def tearDown(self): + self.marionette.actions.release() + self.close_all_windows() + + super(TestPointerActions, self).tearDown() + + def test_click_action(self): + box = self.marionette.find_element(By.ID, "testBox") + box.get_property("localName") + self.assertFalse( + self.marionette.execute_script( + "return document.getElementById('testBox').checked" + ) + ) + self.mouse_chain.click(element=box).perform() + self.assertTrue( + self.marionette.execute_script( + "return document.getElementById('testBox').checked" + ) + ) + + def test_key_action(self): + self.marionette.find_element(By.ID, "textInput").click() + self.key_chain.send_keys("x").perform() + self.assertEqual( + self.marionette.execute_script( + "return document.getElementById('textInput').value" + ), + "testx", + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_chrome_element_css.py b/testing/marionette/harness/marionette_harness/tests/unit/test_chrome_element_css.py new file mode 100644 index 0000000000..cbf326844e --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_chrome_element_css.py @@ -0,0 +1,31 @@ +# 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_driver.by import By + +from marionette_harness import MarionetteTestCase + + +class TestChromeElementCSS(MarionetteTestCase): + def get_element_computed_style(self, element, property): + return self.marionette.execute_script( + """ + const [el, prop] = arguments; + const elStyle = window.getComputedStyle(el); + return elStyle[prop];""", + script_args=(element, property), + sandbox=None, + ) + + def test_we_can_get_css_value_on_chrome_element(self): + with self.marionette.using_context("chrome"): + identity_icon = self.marionette.find_element(By.ID, "identity-icon") + favicon_image = identity_icon.value_of_css_property("list-style-image") + self.assertIn("chrome://", favicon_image) + identity_box = self.marionette.find_element(By.ID, "identity-box") + expected_bg_colour = self.get_element_computed_style( + identity_box, "backgroundColor" + ) + actual_bg_colour = identity_box.value_of_css_property("background-color") + self.assertEqual(expected_bg_colour, actual_bg_colour) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_cli_arguments.py b/testing/marionette/harness/marionette_harness/tests/unit/test_cli_arguments.py new file mode 100644 index 0000000000..c4bbbfad1b --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_cli_arguments.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 copy + +import requests + +from marionette_harness import MarionetteTestCase + + +class TestCommandLineArguments(MarionetteTestCase): + def setUp(self): + super(TestCommandLineArguments, self).setUp() + + self.orig_arguments = copy.copy(self.marionette.instance.app_args) + + def tearDown(self): + self.marionette.instance.app_args = self.orig_arguments + self.marionette.quit(in_app=False, clean=True) + + super(TestCommandLineArguments, self).tearDown() + + def test_debugger_address_cdp_status(self): + # By default Remote Agent is not enabled + debugger_address = self.marionette.session_capabilities.get( + "moz:debuggerAddress" + ) + self.assertIsNone(debugger_address) + + # With BiDi only enabled the capability shouldn't be returned + self.marionette.set_pref("remote.active-protocols", 1) + self.marionette.quit() + + self.marionette.instance.app_args.append("-remote-debugging-port") + self.marionette.start_session() + + debugger_address = self.marionette.session_capabilities.get( + "moz:debuggerAddress" + ) + self.assertIsNone(debugger_address) + + # Clean the profile so that the preference is definetely reset. + self.marionette.quit(in_app=False, clean=True) + + # With all protocols enabled again the capability has to be returned + self.marionette.start_session() + debugger_address = self.marionette.session_capabilities.get( + "moz:debuggerAddress" + ) + + self.assertEqual(debugger_address, "127.0.0.1:9222") + result = requests.get(url="http://{}/json/version".format(debugger_address)) + self.assertTrue(result.ok) + + def test_websocket_url(self): + # By default Remote Agent is not enabled + self.assertNotIn("webSocketUrl", self.marionette.session_capabilities) + + # With CDP only enabled the capability is still not returned + self.marionette.set_pref("remote.active-protocols", 2) + + self.marionette.quit() + self.marionette.instance.app_args.append("-remote-debugging-port") + self.marionette.start_session({"webSocketUrl": True}) + + self.assertNotIn("webSocketUrl", self.marionette.session_capabilities) + + # Clean the profile so that the preference is definetely reset. + self.marionette.quit(in_app=False, clean=True) + + # With all protocols enabled again the capability has to be returned + self.marionette.start_session({"webSocketUrl": True}) + + session_id = self.marionette.session_id + websocket_url = self.marionette.session_capabilities.get("webSocketUrl") + + self.assertEqual( + websocket_url, "ws://127.0.0.1:9222/session/{}".format(session_id) + ) + + # An issue in the command line argument handling lead to open Firefox on + # random URLs when remote-debugging-port is set to an explicit value, on macos. + # See Bug 1724251. + def test_start_page_about_blank(self): + self.marionette.quit() + self.marionette.instance.app_args.append("-remote-debugging-port=0") + self.marionette.start_session({"webSocketUrl": True}) + self.assertEqual(self.marionette.get_url(), "about:blank") + + def test_startup_timeout(self): + try: + self.marionette.quit() + with self.assertRaisesRegexp(IOError, "Process killed after 0s"): + # Use a small enough timeout which should always cause an IOError + self.marionette.start_session(timeout=0) + finally: + self.marionette.start_session() diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_click.py b/testing/marionette/harness/marionette_harness/tests/unit/test_click.py new file mode 100644 index 0000000000..5936be1e69 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_click.py @@ -0,0 +1,571 @@ +# 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 sys +from unittest import skipIf + +from six.moves.urllib.parse import quote + +from marionette_driver import By, errors +from marionette_driver.marionette import Alert + +from marionette_harness import ( + MarionetteTestCase, + WindowManagerMixin, +) + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +# The <a> element in the following HTML is not interactable because it +# is hidden by an overlay when scrolled into the top of the viewport. +# It should be interactable when scrolled in at the bottom of the +# viewport. +fixed_overlay = inline( + """ +<style> +* { margin: 0; padding: 0; } +body { height: 300vh } +div, a { display: block } +div { + background-color: pink; + position: fixed; + width: 100%; + height: 40px; + top: 0; +} +a { + margin-top: 1000px; +} +</style> + +<div>overlay</div> +<a href=#>link</a> + +<script> +window.clicked = false; + +let link = document.querySelector("a"); +link.addEventListener("click", () => window.clicked = true); +</script> +""" +) + + +obscured_overlay = inline( + """ +<style> +* { margin: 0; padding: 0; } +body { height: 100vh } +#overlay { + background-color: pink; + position: absolute; + width: 100%; + height: 100%; +} +</style> + +<div id=overlay></div> +<a id=obscured href=#>link</a> + +<script> +window.clicked = false; + +let link = document.querySelector("#obscured"); +link.addEventListener("click", () => window.clicked = true); +</script> +""" +) + + +class ClickBaseTestCase(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(ClickBaseTestCase, self).setUp() + + # Always use a blank new tab for an empty history + self.new_tab = self.open_tab() + self.marionette.switch_to_window(self.new_tab) + + def tearDown(self): + self.close_all_tabs() + + def test_click(self): + self.marionette.navigate( + inline( + """ + <button>click me</button> + <script> + window.clicks = 0; + let button = document.querySelector("button"); + button.addEventListener("click", () => window.clicks++); + </script> + """ + ) + ) + button = self.marionette.find_element(By.TAG_NAME, "button") + button.click() + self.assertEqual( + 1, self.marionette.execute_script("return window.clicks", sandbox=None) + ) + + def test_click_number_link(self): + test_html = self.marionette.absolute_url("clicks.html") + self.marionette.navigate(test_html) + self.marionette.find_element(By.LINK_TEXT, "333333").click() + self.marionette.find_element(By.ID, "testDiv") + self.assertEqual(self.marionette.title, "Marionette Test") + + def test_clicking_an_element_that_is_not_displayed_raises(self): + self.marionette.navigate( + inline( + """ + <p hidden>foo</p> + """ + ) + ) + + with self.assertRaises(errors.ElementNotInteractableException): + self.marionette.find_element(By.TAG_NAME, "p").click() + + def test_clicking_on_a_multiline_link(self): + test_html = self.marionette.absolute_url("clicks.html") + self.marionette.navigate(test_html) + self.marionette.find_element(By.ID, "overflowLink").click() + self.marionette.find_element(By.ID, "testDiv") + self.assertEqual(self.marionette.title, "Marionette Test") + + def test_click_mathml(self): + self.marionette.navigate( + inline( + """ + <math><mtext id="target">click me</mtext></math> + <script> + window.clicks = 0; + let mtext = document.getElementById("target"); + mtext.addEventListener("click", () => window.clicks++); + </script> + """ + ) + ) + mtext = self.marionette.find_element(By.ID, "target") + mtext.click() + self.assertEqual( + 1, self.marionette.execute_script("return window.clicks", sandbox=None) + ) + + def test_scroll_into_view_near_end(self): + self.marionette.navigate(fixed_overlay) + link = self.marionette.find_element(By.TAG_NAME, "a") + link.click() + self.assertTrue( + self.marionette.execute_script("return window.clicked", sandbox=None) + ) + + def test_inclusive_descendant(self): + self.marionette.navigate( + inline( + """ + <select multiple> + <option>first + <option>second + <option>third + </select>""" + ) + ) + select = self.marionette.find_element(By.TAG_NAME, "select") + + # This tests that the pointer-interactability test does not + # cause an ElementClickInterceptedException. + # + # At a <select multiple>'s in-view centre point, you might + # find a fully rendered <option>. Marionette should test that + # the paint tree at this point _contains_ <option>, not that the + # first element of the paint tree is _equal_ to <select>. + select.click() + + # Bug 1413821 - Click does not select an option on Android + if self.marionette.session_capabilities["browserName"] != "fennec": + self.assertNotEqual(select.get_property("selectedIndex"), -1) + + def test_container_is_select(self): + self.marionette.navigate( + inline( + """ + <select> + <option>foo</option> + </select>""" + ) + ) + option = self.marionette.find_element(By.TAG_NAME, "option") + option.click() + self.assertTrue(option.get_property("selected")) + + def test_container_is_button(self): + self.marionette.navigate( + inline( + """ + <button onclick="window.clicked = true;"> + <span><em>foo</em></span> + </button>""" + ) + ) + span = self.marionette.find_element(By.TAG_NAME, "span") + span.click() + self.assertTrue( + self.marionette.execute_script("return window.clicked", sandbox=None) + ) + + def test_container_element_outside_view(self): + self.marionette.navigate( + inline( + """ + <select style="margin-top: 100vh"> + <option>foo</option> + </select>""" + ) + ) + option = self.marionette.find_element(By.TAG_NAME, "option") + option.click() + self.assertTrue(option.get_property("selected")) + + def test_table_tr(self): + self.marionette.navigate( + inline( + """ + <table> + <tr><td onclick="window.clicked = true;"> + foo + </td></tr> + </table>""" + ) + ) + tr = self.marionette.find_element(By.TAG_NAME, "tr") + tr.click() + self.assertTrue( + self.marionette.execute_script("return window.clicked", sandbox=None) + ) + + +class TestLegacyClick(ClickBaseTestCase): + """Uses legacy Selenium element displayedness checks.""" + + def setUp(self): + super(TestLegacyClick, self).setUp() + + self.marionette.delete_session() + self.marionette.start_session({"moz:webdriverClick": False}) + + +class TestClick(ClickBaseTestCase): + """Uses WebDriver specification compatible element interactability checks.""" + + def setUp(self): + super(TestClick, self).setUp() + + self.marionette.delete_session() + self.marionette.start_session({"moz:webdriverClick": True}) + + def test_click_element_obscured_by_absolute_positioned_element(self): + self.marionette.navigate(obscured_overlay) + overlay = self.marionette.find_element(By.ID, "overlay") + obscured = self.marionette.find_element(By.ID, "obscured") + + overlay.click() + with self.assertRaises(errors.ElementClickInterceptedException): + obscured.click() + + def test_centre_outside_viewport_vertically(self): + self.marionette.navigate( + inline( + """ + <style> + * { margin: 0; padding: 0; } + div { + display: block; + position: absolute; + background-color: blue; + width: 200px; + height: 200px; + + /* move centre point off viewport vertically */ + top: -105px; + } + </style> + + <div onclick="window.clicked = true;"></div>""" + ) + ) + + self.marionette.find_element(By.TAG_NAME, "div").click() + self.assertTrue( + self.marionette.execute_script("return window.clicked", sandbox=None) + ) + + def test_centre_outside_viewport_horizontally(self): + self.marionette.navigate( + inline( + """ + <style> + * { margin: 0; padding: 0; } + div { + display: block; + position: absolute; + background-color: blue; + width: 200px; + height: 200px; + + /* move centre point off viewport horizontally */ + left: -105px; + } + </style> + + <div onclick="window.clicked = true;"></div>""" + ) + ) + + self.marionette.find_element(By.TAG_NAME, "div").click() + self.assertTrue( + self.marionette.execute_script("return window.clicked", sandbox=None) + ) + + def test_centre_outside_viewport(self): + self.marionette.navigate( + inline( + """ + <style> + * { margin: 0; padding: 0; } + div { + display: block; + position: absolute; + background-color: blue; + width: 200px; + height: 200px; + + /* move centre point off viewport */ + left: -105px; + top: -105px; + } + </style> + + <div onclick="window.clicked = true;"></div>""" + ) + ) + + self.marionette.find_element(By.TAG_NAME, "div").click() + self.assertTrue( + self.marionette.execute_script("return window.clicked", sandbox=None) + ) + + def test_css_transforms(self): + self.marionette.navigate( + inline( + """ + <style> + * { margin: 0; padding: 0; } + div { + display: block; + background-color: blue; + width: 200px; + height: 200px; + + transform: translateX(-105px); + } + </style> + + <div onclick="window.clicked = true;"></div>""" + ) + ) + + self.marionette.find_element(By.TAG_NAME, "div").click() + self.assertTrue( + self.marionette.execute_script("return window.clicked", sandbox=None) + ) + + def test_input_file(self): + self.marionette.navigate(inline("<input type=file>")) + with self.assertRaises(errors.InvalidArgumentException): + self.marionette.find_element(By.TAG_NAME, "input").click() + + def test_obscured_element(self): + self.marionette.navigate(obscured_overlay) + overlay = self.marionette.find_element(By.ID, "overlay") + obscured = self.marionette.find_element(By.ID, "obscured") + + overlay.click() + with self.assertRaises(errors.ElementClickInterceptedException): + obscured.click() + self.assertFalse( + self.marionette.execute_script("return window.clicked", sandbox=None) + ) + + def test_pointer_events_none(self): + self.marionette.navigate( + inline( + """ + <button style="pointer-events: none">click me</button> + <script> + window.clicked = false; + let button = document.querySelector("button"); + button.addEventListener("click", () => window.clicked = true); + </script> + """ + ) + ) + button = self.marionette.find_element(By.TAG_NAME, "button") + self.assertEqual("none", button.value_of_css_property("pointer-events")) + + with self.assertRaisesRegexp( + errors.ElementClickInterceptedException, + "does not have pointer events enabled", + ): + button.click() + self.assertFalse( + self.marionette.execute_script("return window.clicked", sandbox=None) + ) + + def test_prevent_default(self): + self.marionette.navigate( + inline( + """ + <button>click me</button> + <script> + let button = document.querySelector("button"); + button.addEventListener("click", event => event.preventDefault()); + </script> + """ + ) + ) + button = self.marionette.find_element(By.TAG_NAME, "button") + # should not time out + button.click() + + def test_stop_propagation(self): + self.marionette.navigate( + inline( + """ + <button>click me</button> + <script> + let button = document.querySelector("button"); + button.addEventListener("click", event => event.stopPropagation()); + </script> + """ + ) + ) + button = self.marionette.find_element(By.TAG_NAME, "button") + # should not time out + button.click() + + def test_stop_immediate_propagation(self): + self.marionette.navigate( + inline( + """ + <button>click me</button> + <script> + let button = document.querySelector("button"); + button.addEventListener("click", event => event.stopImmediatePropagation()); + </script> + """ + ) + ) + button = self.marionette.find_element(By.TAG_NAME, "button") + # should not time out + button.click() + + +class TestClickNavigation(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestClickNavigation, self).setUp() + + # Always use a blank new tab for an empty history + self.new_tab = self.open_tab() + self.marionette.switch_to_window(self.new_tab) + + self.test_page = self.marionette.absolute_url("clicks.html") + self.marionette.navigate(self.test_page) + + def tearDown(self): + self.close_all_tabs() + + def close_notification(self): + try: + with self.marionette.using_context("chrome"): + elem = self.marionette.find_element( + By.CSS_SELECTOR, + "#notification-popup popupnotification .popup-notification-closebutton", + ) + elem.click() + except errors.NoSuchElementException: + pass + + def test_click_link_page_load(self): + self.marionette.find_element(By.LINK_TEXT, "333333").click() + self.assertNotEqual(self.marionette.get_url(), self.test_page) + self.assertEqual(self.marionette.title, "Marionette Test") + + def test_click_link_anchor(self): + self.marionette.find_element(By.ID, "anchor").click() + self.assertEqual(self.marionette.get_url(), "{}#".format(self.test_page)) + + @skipIf( + sys.platform.startswith("win"), + "Bug 1627965 - Skip on Windows for frequent failures", + ) + def test_click_link_install_addon(self): + try: + self.marionette.find_element(By.ID, "install-addon").click() + self.assertEqual(self.marionette.get_url(), self.test_page) + finally: + self.close_notification() + + def test_click_no_link(self): + self.marionette.find_element(By.ID, "links").click() + self.assertEqual(self.marionette.get_url(), self.test_page) + + def test_click_option_navigate(self): + self.marionette.find_element(By.ID, "option").click() + self.marionette.find_element(By.ID, "delay") + + def test_click_remoteness_change(self): + self.marionette.navigate("about:robots") + self.marionette.navigate(self.test_page) + self.marionette.find_element(By.ID, "anchor") + + self.marionette.navigate("about:robots") + with self.assertRaises(errors.NoSuchElementException): + self.marionette.find_element(By.ID, "anchor") + + self.marionette.go_back() + self.marionette.find_element(By.ID, "anchor") + + self.marionette.find_element(By.ID, "history-back").click() + with self.assertRaises(errors.NoSuchElementException): + self.marionette.find_element(By.ID, "anchor") + + +class TestClickCloseContext(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestClickCloseContext, self).setUp() + + self.test_page = self.marionette.absolute_url("clicks.html") + + def tearDown(self): + self.close_all_tabs() + + super(TestClickCloseContext, self).tearDown() + + def test_click_close_tab(self): + new_tab = self.open_tab() + self.marionette.switch_to_window(new_tab) + + self.marionette.navigate(self.test_page) + self.marionette.find_element(By.ID, "close-window").click() + + def test_click_close_window(self): + new_tab = self.open_window() + self.marionette.switch_to_window(new_tab) + + self.marionette.navigate(self.test_page) + self.marionette.find_element(By.ID, "close-window").click() diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_click_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_click_chrome.py new file mode 100644 index 0000000000..1fb4ca89a3 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_click_chrome.py @@ -0,0 +1,33 @@ +# 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_driver.by import By + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestClickChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestClickChrome, self).setUp() + + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + + super(TestClickChrome, self).tearDown() + + def test_click(self): + win = self.open_chrome_window("chrome://remote/content/marionette/test.xhtml") + self.marionette.switch_to_window(win) + + def checked(): + return self.marionette.execute_script( + "return arguments[0].checked", script_args=[box] + ) + + box = self.marionette.find_element(By.ID, "testBox") + self.assertFalse(checked()) + box.click() + self.assertTrue(checked()) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_click_scrolling.py b/testing/marionette/harness/marionette_harness/tests/unit/test_click_scrolling.py new file mode 100644 index 0000000000..ade5a21b36 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_click_scrolling.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/. + +from six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_driver.errors import MoveTargetOutOfBoundsException + +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestClickScrolling(MarionetteTestCase): + def test_clicking_on_anchor_scrolls_page(self): + self.marionette.navigate( + inline( + """ + <a href="#content">Link to content</a> + <div id="content" style="margin-top: 205vh;">Text</div> + """ + ) + ) + + # Focusing on to click, but not actually following, + # the link will scroll it in to view, which is a few + # pixels further than 0 + self.marionette.find_element(By.CSS_SELECTOR, "a").click() + + y_offset = self.marionette.execute_script( + """ + var pageY; + if (typeof(window.pageYOffset) == 'number') { + pageY = window.pageYOffset; + } else { + pageY = document.documentElement.scrollTop; + } + return pageY; + """ + ) + + self.assertGreater(y_offset, 300) + + def test_should_scroll_to_click_on_an_element_hidden_by_overflow(self): + test_html = self.marionette.absolute_url("click_out_of_bounds_overflow.html") + self.marionette.navigate(test_html) + + link = self.marionette.find_element(By.ID, "link") + try: + link.click() + except MoveTargetOutOfBoundsException: + self.fail("Should not be out of bounds") + + def test_should_not_scroll_elements_if_click_point_is_in_view(self): + test_html = self.marionette.absolute_url("element_outside_viewport.html") + + for s in ["top", "right", "bottom", "left"]: + for p in ["50", "30"]: + self.marionette.navigate(test_html) + scroll = self.marionette.execute_script( + "return [window.scrollX, window.scrollY];" + ) + self.marionette.find_element(By.ID, "{0}-{1}".format(s, p)).click() + self.assertEqual( + scroll, + self.marionette.execute_script( + "return [window.scrollX, window.scrollY];" + ), + ) + + def test_do_not_scroll_again_if_element_is_already_in_view(self): + self.marionette.navigate( + inline( + """ + <div style="height: 200vh;"> + <button id="button1" style="margin-top: 105vh">Button1</button> + <button id="button2" style="position: relative; top: 5em">Button2</button> + </div> + """ + ) + ) + button1 = self.marionette.find_element(By.ID, "button1") + button2 = self.marionette.find_element(By.ID, "button2") + + button2.click() + scroll_top = self.marionette.execute_script("return document.body.scrollTop;") + button1.click() + + self.assertEqual( + scroll_top, + self.marionette.execute_script("return document.body.scrollTop;"), + ) + + def test_scroll_radio_button_into_view(self): + self.marionette.navigate( + inline( + """ + <input type="radio" id="radio" style="margin-top: 105vh;"> + """ + ) + ) + self.marionette.find_element(By.ID, "radio").click() + + def test_overflow_scroll_do_not_scroll_elements_which_are_visible(self): + self.marionette.navigate( + inline( + """ + <ul style='overflow: scroll; height: 8em; line-height: 3em'> + <li></li> + <li id="desired">Text</li> + <li></li> + <li></li> + </ul> + """ + ) + ) + + list_el = self.marionette.find_element(By.TAG_NAME, "ul") + expected_y_offset = self.marionette.execute_script( + "return arguments[0].scrollTop;", script_args=(list_el,) + ) + + item = list_el.find_element(By.ID, "desired") + item.click() + + y_offset = self.marionette.execute_script( + "return arguments[0].scrollTop;", script_args=(list_el,) + ) + self.assertEqual(expected_y_offset, y_offset) + + def test_overflow_scroll_click_on_hidden_element(self): + self.marionette.navigate( + inline( + """ + Result: <span id="result"></span> + <ul style='overflow: scroll; width: 150px; height: 8em; line-height: 4em' + onclick="document.getElementById('result').innerText = event.target.id;"> + <li>line1</li> + <li>line2</li> + <li>line3</li> + <li id="line4">line4</li> + </ul> + """ + ) + ) + + self.marionette.find_element(By.ID, "line4").click() + self.assertEqual("line4", self.marionette.find_element(By.ID, "result").text) + + def test_overflow_scroll_vertically_for_click_point_outside_of_viewport(self): + self.marionette.navigate( + inline( + """ + Result: <span id="result"></span> + <div style='overflow: scroll; width: 100px; height: 100px; background-color: yellow;'> + <div id="inner" style="width: 100px; height: 300px; background-color: green;" + onclick="document.getElementById('result').innerText = event.type" ></div> + </div> + """ + ) + ) + + self.marionette.find_element(By.ID, "inner").click() + self.assertEqual("click", self.marionette.find_element(By.ID, "result").text) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_context.py b/testing/marionette/harness/marionette_harness/tests/unit/test_context.py new file mode 100644 index 0000000000..4f2c077677 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_context.py @@ -0,0 +1,82 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from marionette_driver.decorators import using_context +from marionette_driver.errors import MarionetteException +from marionette_harness import MarionetteTestCase + + +class ContextTestCase(MarionetteTestCase): + def setUp(self): + super(ContextTestCase, self).setUp() + + # shortcuts to improve readability of these tests + self.chrome = self.marionette.CONTEXT_CHROME + self.content = self.marionette.CONTEXT_CONTENT + + self.assertEqual(self.get_context(), self.content) + + test_url = self.marionette.absolute_url("empty.html") + self.marionette.navigate(test_url) + + def get_context(self): + return self.marionette._send_message("Marionette:GetContext", key="value") + + +class TestSetContext(ContextTestCase): + def test_switch_context(self): + self.marionette.set_context(self.chrome) + self.assertEqual(self.get_context(), self.chrome) + + self.marionette.set_context(self.content) + self.assertEqual(self.get_context(), self.content) + + def test_invalid_context(self): + with self.assertRaises(ValueError): + self.marionette.set_context("foobar") + + +class TestUsingContext(ContextTestCase): + def test_set_different_context_using_with_block(self): + with self.marionette.using_context(self.chrome): + self.assertEqual(self.get_context(), self.chrome) + self.assertEqual(self.get_context(), self.content) + + def test_set_same_context_using_with_block(self): + with self.marionette.using_context(self.content): + self.assertEqual(self.get_context(), self.content) + self.assertEqual(self.get_context(), self.content) + + def test_nested_with_blocks(self): + with self.marionette.using_context(self.chrome): + self.assertEqual(self.get_context(), self.chrome) + with self.marionette.using_context(self.content): + self.assertEqual(self.get_context(), self.content) + self.assertEqual(self.get_context(), self.chrome) + self.assertEqual(self.get_context(), self.content) + + def test_set_scope_while_in_with_block(self): + with self.marionette.using_context(self.chrome): + self.assertEqual(self.get_context(), self.chrome) + self.marionette.set_context(self.content) + self.assertEqual(self.get_context(), self.content) + self.assertEqual(self.get_context(), self.content) + + def test_exception_raised_while_in_with_block_is_propagated(self): + with self.assertRaises(MarionetteException): + with self.marionette.using_context(self.chrome): + raise MarionetteException + self.assertEqual(self.get_context(), self.content) + + def test_with_using_context_decorator(self): + @using_context("content") + def inner_content(m): + self.assertEqual(self.get_context(), "content") + + @using_context("chrome") + def inner_chrome(m): + self.assertEqual(self.get_context(), "chrome") + + inner_content(self.marionette) + inner_chrome(self.marionette) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_cookies.py b/testing/marionette/harness/marionette_harness/tests/unit/test_cookies.py new file mode 100644 index 0000000000..ea51214909 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_cookies.py @@ -0,0 +1,115 @@ +# 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 calendar +import random +import time + +from marionette_driver.errors import UnsupportedOperationException +from marionette_harness import MarionetteTestCase + + +class CookieTest(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + test_url = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_url) + self.COOKIE_A = {"name": "foo", "value": "bar", "path": "/", "secure": False} + + def tearDown(self): + self.marionette.delete_all_cookies() + MarionetteTestCase.tearDown(self) + + def test_add_cookie(self): + self.marionette.add_cookie(self.COOKIE_A) + cookie_returned = str(self.marionette.execute_script("return document.cookie")) + self.assertTrue(self.COOKIE_A["name"] in cookie_returned) + + def test_adding_a_cookie_that_expired_in_the_past(self): + cookie = self.COOKIE_A.copy() + cookie["expiry"] = calendar.timegm(time.gmtime()) - (60 * 60 * 24) + self.marionette.add_cookie(cookie) + cookies = self.marionette.get_cookies() + self.assertEqual(0, len(cookies)) + + def test_chrome_error(self): + with self.marionette.using_context("chrome"): + self.assertRaises( + UnsupportedOperationException, self.marionette.add_cookie, self.COOKIE_A + ) + self.assertRaises( + UnsupportedOperationException, + self.marionette.delete_cookie, + self.COOKIE_A, + ) + self.assertRaises( + UnsupportedOperationException, self.marionette.delete_all_cookies + ) + self.assertRaises( + UnsupportedOperationException, self.marionette.get_cookies + ) + + def test_delete_all_cookie(self): + self.marionette.add_cookie(self.COOKIE_A) + cookie_returned = str(self.marionette.execute_script("return document.cookie")) + print(cookie_returned) + self.assertTrue(self.COOKIE_A["name"] in cookie_returned) + self.marionette.delete_all_cookies() + self.assertFalse(self.marionette.get_cookies()) + + def test_delete_cookie(self): + self.marionette.add_cookie(self.COOKIE_A) + cookie_returned = str(self.marionette.execute_script("return document.cookie")) + self.assertTrue(self.COOKIE_A["name"] in cookie_returned) + self.marionette.delete_cookie("foo") + cookie_returned = str(self.marionette.execute_script("return document.cookie")) + self.assertFalse(self.COOKIE_A["name"] in cookie_returned) + + def test_should_get_cookie_by_name(self): + key = "key_{}".format(int(random.random() * 10000000)) + self.marionette.execute_script( + "document.cookie = arguments[0] + '=set';", [key] + ) + + cookie = self.marionette.get_cookie(key) + self.assertEqual("set", cookie["value"]) + + def test_get_all_cookies(self): + key1 = "key_{}".format(int(random.random() * 10000000)) + key2 = "key_{}".format(int(random.random() * 10000000)) + + cookies = self.marionette.get_cookies() + count = len(cookies) + + one = {"name": key1, "value": "value"} + two = {"name": key2, "value": "value"} + + self.marionette.add_cookie(one) + self.marionette.add_cookie(two) + + test_url = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_url) + cookies = self.marionette.get_cookies() + self.assertEqual(count + 2, len(cookies)) + + def test_should_not_delete_cookies_with_a_similar_name(self): + cookieOneName = "fish" + cookie1 = {"name": cookieOneName, "value": "cod"} + cookie2 = {"name": cookieOneName + "x", "value": "earth"} + self.marionette.add_cookie(cookie1) + self.marionette.add_cookie(cookie2) + + self.marionette.delete_cookie(cookieOneName) + cookies = self.marionette.get_cookies() + + self.assertFalse(cookie1["name"] == cookies[0]["name"], msg=str(cookies)) + self.assertEqual(cookie2["name"], cookies[0]["name"], msg=str(cookies)) + + def test_we_get_required_elements_when_available(self): + self.marionette.add_cookie(self.COOKIE_A) + cookies = self.marionette.get_cookies() + + self.assertIn("name", cookies[0], "name not available") + self.assertIn("value", cookies[0], "value not available") + self.assertIn("httpOnly", cookies[0], "httpOnly not available") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_crash.py b/testing/marionette/harness/marionette_harness/tests/unit/test_crash.py new file mode 100644 index 0000000000..b413adda0d --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_crash.py @@ -0,0 +1,211 @@ +# 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 glob +import os +import shutil +import sys + +from io import StringIO + +from marionette_driver import Wait +from marionette_driver.errors import ( + InvalidSessionIdException, + NoSuchWindowException, + TimeoutException, +) + +from marionette_harness import MarionetteTestCase, expectedFailure + +# Import runner module to monkey patch mozcrash module +from mozrunner.base import runner + + +class MockMozCrash(object): + """Mock object to replace original mozcrash methods.""" + + def __init__(self, marionette): + self.marionette = marionette + + with self.marionette.using_context("chrome"): + self.crash_reporter_enabled = self.marionette.execute_script( + """ + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + return AppConstants.MOZ_CRASHREPORTER; + """ + ) + + def check_for_crashes(self, dump_directory, *args, **kwargs): + if self.crash_reporter_enabled: + # Workaround until bug 1376795 has been fixed + # Wait at maximum 5s for the minidump files being created + # minidump_files = glob.glob('{}/*.dmp'.format(dump_directory)) + try: + minidump_files = Wait(None, timeout=5).until( + lambda _: glob.glob("{}/*.dmp".format(dump_directory)) + ) + except TimeoutException: + minidump_files = [] + + if os.path.isdir(dump_directory): + shutil.rmtree(dump_directory) + + return len(minidump_files) + else: + return len(minidump_files) == 0 + + def log_crashes(self, logger, dump_directory, *args, **kwargs): + return self.check_for_crashes(dump_directory, *args, **kwargs) + + +class BaseCrashTestCase(MarionetteTestCase): + # Reduce the timeout for faster processing of the tests + socket_timeout = 10 + + def setUp(self): + super(BaseCrashTestCase, self).setUp() + + # Monkey patch mozcrash to avoid crash info output only for our triggered crashes. + mozcrash_mock = MockMozCrash(self.marionette) + if not mozcrash_mock.crash_reporter_enabled: + self.skipTest("Crash reporter disabled") + return + + self.mozcrash = runner.mozcrash + runner.mozcrash = mozcrash_mock + + self.crash_count = self.marionette.crashed + self.pid = self.marionette.process_id + + def tearDown(self): + # Replace mockup with original mozcrash instance + runner.mozcrash = self.mozcrash + + self.marionette.crashed = self.crash_count + + super(BaseCrashTestCase, self).tearDown() + + def crash(self, parent=True): + socket_timeout = self.marionette.client.socket_timeout + self.marionette.client.socket_timeout = self.socket_timeout + + self.marionette.set_context("content") + try: + self.marionette.navigate( + "about:crash{}".format("parent" if parent else "content") + ) + finally: + self.marionette.client.socket_timeout = socket_timeout + + +class TestCrash(BaseCrashTestCase): + def setUp(self): + if os.environ.get("MOZ_AUTOMATION"): + # Capture stdout, otherwise the Gecko output causes mozharness to fail + # the task due to "A content process has crashed" appearing in the log. + # To view stdout for debugging, use `print(self.new_out.getvalue())` + print( + "Suppressing GECKO output. To view, add `print(self.new_out.getvalue())` " + "to the end of this test." + ) + self.new_out, self.new_err = StringIO(), StringIO() + self.old_out, self.old_err = sys.stdout, sys.stderr + sys.stdout, sys.stderr = self.new_out, self.new_err + + super(TestCrash, self).setUp() + + def tearDown(self): + super(TestCrash, self).tearDown() + + if os.environ.get("MOZ_AUTOMATION"): + sys.stdout, sys.stderr = self.old_out, self.old_err + + def test_crash_chrome_process(self): + self.assertRaisesRegexp(IOError, "Process crashed", self.crash, parent=True) + + # A crash results in a non zero exit code + self.assertNotIn(self.marionette.instance.runner.returncode, (None, 0)) + + self.assertEqual(self.marionette.crashed, 1) + self.assertIsNone(self.marionette.session) + with self.assertRaisesRegexp( + InvalidSessionIdException, "Please start a session" + ): + self.marionette.get_url() + + self.marionette.start_session() + self.assertNotEqual(self.marionette.process_id, self.pid) + + self.marionette.get_url() + + def test_crash_content_process(self): + # For a content process crash and MOZ_CRASHREPORTER_SHUTDOWN set the top + # browsing context will be gone first. As such the raised NoSuchWindowException + # has to be ignored. To check for the IOError, further commands have to + # be executed until the process is gone. + with self.assertRaisesRegexp(IOError, "Content process crashed"): + self.crash(parent=False) + Wait( + self.marionette, + timeout=self.socket_timeout, + ignored_exceptions=NoSuchWindowException, + ).until( + lambda _: self.marionette.get_url(), + message="Expected IOError exception for content crash not raised.", + ) + + # A crash when loading about:crashcontent results in a SIGUSR1 exit code. + self.assertEqual(self.marionette.instance.runner.returncode, 245) + + self.assertEqual(self.marionette.crashed, 1) + self.assertIsNone(self.marionette.session) + with self.assertRaisesRegexp( + InvalidSessionIdException, "Please start a session" + ): + self.marionette.get_url() + + self.marionette.start_session() + self.assertNotEqual(self.marionette.process_id, self.pid) + self.marionette.get_url() + + @expectedFailure + def test_unexpected_crash(self): + self.crash(parent=True) + + +class TestCrashInSetUp(BaseCrashTestCase): + def setUp(self): + super(TestCrashInSetUp, self).setUp() + + self.assertRaisesRegexp(IOError, "Process crashed", self.crash, parent=True) + + # A crash results in a non zero exit code + self.assertNotIn(self.marionette.instance.runner.returncode, (None, 0)) + + self.assertEqual(self.marionette.crashed, 1) + self.assertIsNone(self.marionette.session) + + def test_crash_in_setup(self): + self.marionette.start_session() + self.assertNotEqual(self.marionette.process_id, self.pid) + + +class TestCrashInTearDown(BaseCrashTestCase): + def tearDown(self): + try: + self.assertRaisesRegexp(IOError, "Process crashed", self.crash, parent=True) + + # A crash results in a non zero exit code + self.assertNotIn(self.marionette.instance.runner.returncode, (None, 0)) + + self.assertEqual(self.marionette.crashed, 1) + self.assertIsNone(self.marionette.session) + + finally: + super(TestCrashInTearDown, self).tearDown() + + def test_crash_in_teardown(self): + pass diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_data_driven.py b/testing/marionette/harness/marionette_harness/tests/unit/test_data_driven.py new file mode 100644 index 0000000000..b7d1ecf5ff --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_data_driven.py @@ -0,0 +1,72 @@ +# 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 six + +from marionette_harness.marionette_test import ( + parameterized, + with_parameters, + MetaParameterized, + MarionetteTestCase, +) + + +@six.add_metaclass(MetaParameterized) +class Parameterizable(object): + pass + + +class TestDataDriven(MarionetteTestCase): + def test_parameterized(self): + class Test(Parameterizable): + def __init__(self): + self.parameters = [] + + @parameterized("1", "thing", named=43) + @parameterized("2", "thing2") + def test(self, thing, named=None): + self.parameters.append((thing, named)) + + self.assertFalse(hasattr(Test, "test")) + self.assertTrue(hasattr(Test, "test_1")) + self.assertTrue(hasattr(Test, "test_2")) + + test = Test() + test.test_1() + test.test_2() + + self.assertEqual(test.parameters, [("thing", 43), ("thing2", None)]) + + def test_with_parameters(self): + DATA = [("1", ("thing",), {"named": 43}), ("2", ("thing2",), {"named": None})] + + class Test(Parameterizable): + def __init__(self): + self.parameters = [] + + @with_parameters(DATA) + def test(self, thing, named=None): + self.parameters.append((thing, named)) + + self.assertFalse(hasattr(Test, "test")) + self.assertTrue(hasattr(Test, "test_1")) + self.assertTrue(hasattr(Test, "test_2")) + + test = Test() + test.test_1() + test.test_2() + + self.assertEqual(test.parameters, [("thing", 43), ("thing2", None)]) + + def test_parameterized_same_name_raises_error(self): + with self.assertRaises(KeyError): + + class Test(Parameterizable): + @parameterized("1", "thing", named=43) + @parameterized("1", "thing2") + def test(self, thing, named=None): + pass + + def test_marionette_test_case_is_parameterizable(self): + self.assertTrue(isinstance(MarionetteTestCase, MetaParameterized)) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_date_time_value.py b/testing/marionette/harness/marionette_harness/tests/unit/test_date_time_value.py new file mode 100644 index 0000000000..7bab80ee8f --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_date_time_value.py @@ -0,0 +1,33 @@ +# 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 datetime import datetime + +from six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_driver.date_time_value import DateTimeValue +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestDateTime(MarionetteTestCase): + def test_set_date(self): + self.marionette.navigate(inline("<input id='date-test' type='date'/>")) + + element = self.marionette.find_element(By.ID, "date-test") + dt_value = DateTimeValue(element) + dt_value.date = datetime(1998, 6, 2) + self.assertEqual("1998-06-02", element.get_property("value")) + + def test_set_time(self): + self.marionette.navigate(inline("<input id='time-test' type='time'/>")) + + element = self.marionette.find_element(By.ID, "time-test") + dt_value = DateTimeValue(element) + dt_value.time = datetime(1998, 11, 19, 9, 8, 7) + self.assertEqual("09:08:07", element.get_property("value")) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_element_id.py b/testing/marionette/harness/marionette_harness/tests/unit/test_element_id.py new file mode 100644 index 0000000000..c7827daa08 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_element_id.py @@ -0,0 +1,55 @@ +# 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 re +from urllib.parse import quote + +from marionette_driver.by import By +from marionette_driver.errors import NoSuchElementException, InvalidSelectorException +from marionette_driver.marionette import WebElement + +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +id_html = inline("<p id=foo></p>") + + +class TestElementID(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + self.marionette.timeout.implicit = 0 + + def test_id_is_valid_uuid(self): + self.marionette.navigate(id_html) + el = self.marionette.find_element(By.TAG_NAME, "p") + uuid_regex = re.compile( + "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + ) + self.assertIsNotNone( + re.search(uuid_regex, el.id), + "UUID for the WebElement is not valid. ID is {}".format(el.id), + ) + + def test_id_identical_for_the_same_element(self): + self.marionette.navigate(id_html) + found = self.marionette.find_element(By.ID, "foo") + self.assertIsInstance(found, WebElement) + + found_again = self.marionette.find_element(By.ID, "foo") + self.assertEqual(found_again, found) + + def test_id_unique_per_session(self): + self.marionette.navigate(id_html) + found = self.marionette.find_element(By.ID, "foo") + self.assertIsInstance(found, WebElement) + + self.marionette.delete_session() + self.marionette.start_session() + + found_again = self.marionette.find_element(By.ID, "foo") + self.assertNotEqual(found_again.id, found.id) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_element_id_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_element_id_chrome.py new file mode 100644 index 0000000000..6c9f01f339 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_element_id_chrome.py @@ -0,0 +1,88 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from marionette_driver.by import By +from marionette_driver.errors import NoSuchElementException +from marionette_driver.marionette import WebElement + +from marionette_harness import MarionetteTestCase, parameterized, WindowManagerMixin + + +PAGE_XHTML = "chrome://remote/content/marionette/test_no_xul.xhtml" +PAGE_XUL = "chrome://remote/content/marionette/test.xhtml" + + +class TestElementIDChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestElementIDChrome, self).setUp() + + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + + super(TestElementIDChrome, self).tearDown() + + @parameterized("XUL", PAGE_XUL) + @parameterized("XHTML", PAGE_XHTML) + def test_id_identical_for_the_same_element(self, chrome_url): + win = self.open_chrome_window(chrome_url) + self.marionette.switch_to_window(win) + + found_el = self.marionette.find_element(By.ID, "textInput") + self.assertEqual(WebElement, type(found_el)) + + found_el_new = self.marionette.find_element(By.ID, "textInput") + self.assertEqual(found_el_new.id, found_el.id) + + @parameterized("XUL", PAGE_XUL) + @parameterized("XHTML", PAGE_XHTML) + def test_id_unique_per_session(self, chrome_url): + win = self.open_chrome_window(chrome_url) + self.marionette.switch_to_window(win) + + found_el = self.marionette.find_element(By.ID, "textInput") + self.assertEqual(WebElement, type(found_el)) + + self.marionette.delete_session() + self.marionette.start_session() + + self.marionette.set_context("chrome") + self.marionette.switch_to_window(win) + + found_el_new = self.marionette.find_element(By.ID, "textInput") + self.assertNotEqual(found_el_new.id, found_el.id) + + @parameterized("XUL", PAGE_XUL) + @parameterized("XHTML", PAGE_XHTML) + def test_id_no_such_element_in_another_chrome_window(self, chrome_url): + original_handle = self.marionette.current_window_handle + + win = self.open_chrome_window(chrome_url) + self.marionette.switch_to_window(win) + + found_el = self.marionette.find_element(By.ID, "textInput") + self.assertEqual(WebElement, type(found_el)) + + self.marionette.switch_to_window(original_handle) + + with self.assertRaises(NoSuchElementException): + found_el.get_property("localName") + + @parameterized("XUL", PAGE_XUL) + @parameterized("XHTML", PAGE_XHTML) + def test_id_removed_when_chrome_window_is_closed(self, chrome_url): + original_handle = self.marionette.current_window_handle + + win = self.open_chrome_window(chrome_url) + self.marionette.switch_to_window(win) + + found_el = self.marionette.find_element(By.ID, "textInput") + self.assertEqual(WebElement, type(found_el)) + + self.marionette.close_chrome_window() + self.marionette.switch_to_window(original_handle) + + with self.assertRaises(NoSuchElementException): + found_el.get_property("localName") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_element_rect.py b/testing/marionette/harness/marionette_harness/tests/unit/test_element_rect.py new file mode 100644 index 0000000000..4eea9a2c40 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_element_rect.py @@ -0,0 +1,22 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestElementSize(MarionetteTestCase): + def test_payload(self): + self.marionette.navigate(inline("""<a href="#">link</a>""")) + rect = self.marionette.find_element(By.LINK_TEXT, "link").rect + self.assertTrue(rect["x"] > 0) + self.assertTrue(rect["y"] > 0) + self.assertTrue(rect["width"] > 0) + self.assertTrue(rect["height"] > 0) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_element_rect_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_element_rect_chrome.py new file mode 100644 index 0000000000..2ea46182c2 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_element_rect_chrome.py @@ -0,0 +1,30 @@ +# 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_driver.by import By + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestElementSizeChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestElementSizeChrome, self).setUp() + + self.marionette.set_context("chrome") + + new_window = self.open_chrome_window( + "chrome://remote/content/marionette/test.xhtml" + ) + self.marionette.switch_to_window(new_window) + + def tearDown(self): + self.close_all_windows() + super(TestElementSizeChrome, self).tearDown() + + def test_payload(self): + rect = self.marionette.find_element(By.ID, "textInput").rect + self.assertTrue(rect["x"] > 0) + self.assertTrue(rect["y"] > 0) + self.assertTrue(rect["width"] > 0) + self.assertTrue(rect["height"] > 0) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_element_state.py b/testing/marionette/harness/marionette_harness/tests/unit/test_element_state.py new file mode 100644 index 0000000000..3122cc42b8 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_element_state.py @@ -0,0 +1,175 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import types + +import six +from six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_harness import MarionetteTestCase + + +boolean_attributes = { + "audio": ["autoplay", "controls", "loop", "muted"], + "button": ["autofocus", "disabled", "formnovalidate"], + "details": ["open"], + "dialog": ["open"], + "fieldset": ["disabled"], + "form": ["novalidate"], + "iframe": ["allowfullscreen"], + "img": ["ismap"], + "input": [ + "autofocus", + "checked", + "disabled", + "formnovalidate", + "multiple", + "readonly", + "required", + ], + "menuitem": ["checked", "default", "disabled"], + "ol": ["reversed"], + "optgroup": ["disabled"], + "option": ["disabled", "selected"], + "script": ["async", "defer"], + "select": ["autofocus", "disabled", "multiple", "required"], + "textarea": ["autofocus", "disabled", "readonly", "required"], + "track": ["default"], + "video": ["autoplay", "controls", "loop", "muted"], +} + + +def inline(doc, doctype="html"): + if doctype == "html": + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + elif doctype == "xhtml": + return "data:application/xhtml+xml,{}".format( + quote( + r"""<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> + <head> + <title>XHTML might be the future</title> + </head> + + <body> + {} + </body> +</html>""".format( + doc + ) + ) + ) + + +attribute = inline("<input foo=bar>") +input = inline("<input>") +disabled = inline("<input disabled=baz>") +check = inline("<input type=checkbox>") + + +class TestIsElementEnabled(MarionetteTestCase): + def test_is_enabled(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + l = self.marionette.find_element(By.NAME, "myCheckBox") + self.assertTrue(l.is_enabled()) + self.marionette.execute_script("arguments[0].disabled = true;", [l]) + self.assertFalse(l.is_enabled()) + + +class TestIsElementDisplayed(MarionetteTestCase): + def test_is_displayed(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + l = self.marionette.find_element(By.NAME, "myCheckBox") + self.assertTrue(l.is_displayed()) + self.marionette.execute_script("arguments[0].hidden = true;", [l]) + self.assertFalse(l.is_displayed()) + + +class TestGetElementAttribute(MarionetteTestCase): + def test_normal_attribute(self): + self.marionette.navigate(inline("<p style=foo>")) + el = self.marionette.find_element(By.TAG_NAME, "p") + attr = el.get_attribute("style") + self.assertIsInstance(attr, six.string_types) + self.assertEqual("foo", attr) + + def test_boolean_attributes(self): + for tag, attrs in six.iteritems(boolean_attributes): + for attr in attrs: + print("testing boolean attribute <{0} {1}>".format(tag, attr)) + doc = inline("<{0} {1}>".format(tag, attr)) + self.marionette.navigate(doc) + el = self.marionette.find_element(By.TAG_NAME, tag) + res = el.get_attribute(attr) + self.assertIsInstance(res, six.string_types) + self.assertEqual("true", res) + + def test_global_boolean_attributes(self): + self.marionette.navigate(inline("<p hidden>foo")) + el = self.marionette.find_element(By.TAG_NAME, "p") + attr = el.get_attribute("hidden") + self.assertIsInstance(attr, six.string_types) + self.assertEqual("true", attr) + + self.marionette.navigate(inline("<p>foo")) + el = self.marionette.find_element(By.TAG_NAME, "p") + attr = el.get_attribute("hidden") + self.assertIsNone(attr) + + self.marionette.navigate(inline("<p itemscope>foo")) + el = self.marionette.find_element(By.TAG_NAME, "p") + attr = el.get_attribute("itemscope") + self.assertIsInstance(attr, six.string_types) + self.assertEqual("true", attr) + + self.marionette.navigate(inline("<p>foo")) + el = self.marionette.find_element(By.TAG_NAME, "p") + attr = el.get_attribute("itemscope") + self.assertIsNone(attr) + + # TODO(ato): Test for custom elements + + def test_xhtml(self): + doc = inline('<p hidden="true">foo</p>', doctype="xhtml") + self.marionette.navigate(doc) + el = self.marionette.find_element(By.TAG_NAME, "p") + attr = el.get_attribute("hidden") + self.assertIsInstance(attr, six.string_types) + self.assertEqual("true", attr) + + +class TestGetElementProperty(MarionetteTestCase): + def test_get(self): + self.marionette.navigate(disabled) + el = self.marionette.find_element(By.TAG_NAME, "input") + prop = el.get_property("disabled") + self.assertIsInstance(prop, bool) + self.assertTrue(prop) + + def test_missing_property_returns_default(self): + self.marionette.navigate(input) + el = self.marionette.find_element(By.TAG_NAME, "input") + prop = el.get_property("checked") + self.assertIsInstance(prop, bool) + self.assertFalse(prop) + + def test_attribute_not_returned(self): + self.marionette.navigate(attribute) + el = self.marionette.find_element(By.TAG_NAME, "input") + self.assertEqual(el.get_property("foo"), None) + + def test_manipulated_element(self): + self.marionette.navigate(check) + el = self.marionette.find_element(By.TAG_NAME, "input") + self.assertEqual(el.get_property("checked"), False) + + el.click() + self.assertEqual(el.get_property("checked"), True) + + el.click() + self.assertEqual(el.get_property("checked"), False) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_element_state_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_element_state_chrome.py new file mode 100644 index 0000000000..a39c907952 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_element_state_chrome.py @@ -0,0 +1,56 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from marionette_driver.by import By + +from marionette_harness import MarionetteTestCase, skip, WindowManagerMixin + + +class TestElementState(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestElementState, self).setUp() + + self.marionette.set_context("chrome") + + self.win = self.open_chrome_window( + "chrome://remote/content/marionette/test.xhtml" + ) + self.marionette.switch_to_window(self.win) + + def tearDown(self): + self.close_all_windows() + + super(TestElementState, self).tearDown() + + def test_is_displayed(self): + l = self.marionette.find_element(By.ID, "textInput") + self.assertTrue(l.is_displayed()) + self.marionette.execute_script("arguments[0].hidden = true;", [l]) + self.assertFalse(l.is_displayed()) + self.marionette.execute_script("arguments[0].hidden = false;", [l]) + + def test_enabled(self): + l = self.marionette.find_element(By.ID, "textInput") + self.assertTrue(l.is_enabled()) + self.marionette.execute_script("arguments[0].disabled = true;", [l]) + self.assertFalse(l.is_enabled()) + self.marionette.execute_script("arguments[0].disabled = false;", [l]) + + def test_can_get_element_rect(self): + l = self.marionette.find_element(By.ID, "textInput") + rect = l.rect + self.assertTrue(rect["x"] > 0) + self.assertTrue(rect["y"] > 0) + + def test_get_attribute(self): + el = self.marionette.execute_script( + "return window.document.getElementById('textInput');" + ) + self.assertEqual(el.get_attribute("id"), "textInput") + + def test_get_property(self): + el = self.marionette.execute_script( + "return window.document.getElementById('textInput');" + ) + self.assertEqual(el.get_property("id"), "textInput") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_errors.py b/testing/marionette/harness/marionette_harness/tests/unit/test_errors.py new file mode 100644 index 0000000000..53984dba48 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_errors.py @@ -0,0 +1,105 @@ +# 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 sys + +import six + +from marionette_driver import errors + +from marionette_harness import marionette_test + + +def fake_cause(): + try: + raise ValueError("bar") + except ValueError: + return sys.exc_info() + + +message = "foo" +unicode_message = "\u201Cfoo" +cause = fake_cause() +stacktrace = "first\nsecond" + + +class TestErrors(marionette_test.MarionetteTestCase): + def test_defaults(self): + exc = errors.MarionetteException() + self.assertEqual(str(exc), "None") + self.assertIsNone(exc.cause) + self.assertIsNone(exc.stacktrace) + + def test_construction(self): + exc = errors.MarionetteException( + message=message, cause=cause, stacktrace=stacktrace + ) + self.assertEqual(exc.message, message) + self.assertEqual(exc.cause, cause) + self.assertEqual(exc.stacktrace, stacktrace) + + def test_str_message(self): + exc = errors.MarionetteException( + message=message, cause=cause, stacktrace=stacktrace + ) + r = str(exc) + self.assertIn(message, r) + self.assertIn(", caused by {0!r}".format(cause[0]), r) + self.assertIn("\nstacktrace:\n\tfirst\n\tsecond", r) + + def test_unicode_message(self): + exc = errors.MarionetteException( + message=unicode_message, cause=cause, stacktrace=stacktrace + ) + r = six.text_type(exc) + self.assertIn(unicode_message, r) + self.assertIn(", caused by {0!r}".format(cause[0]), r) + self.assertIn("\nstacktrace:\n\tfirst\n\tsecond", r) + + def test_unicode_message_as_str(self): + exc = errors.MarionetteException( + message=unicode_message, cause=cause, stacktrace=stacktrace + ) + r = str(exc) + self.assertIn(six.ensure_str(unicode_message, encoding="utf-8"), r) + self.assertIn(", caused by {0!r}".format(cause[0]), r) + self.assertIn("\nstacktrace:\n\tfirst\n\tsecond", r) + + def test_cause_string(self): + exc = errors.MarionetteException(cause="foo") + self.assertEqual(exc.cause, "foo") + r = str(exc) + self.assertIn(", caused by foo", r) + + def test_cause_tuple(self): + exc = errors.MarionetteException(cause=cause) + self.assertEqual(exc.cause, cause) + r = str(exc) + self.assertIn(", caused by {0!r}".format(cause[0]), r) + + +class TestLookup(marionette_test.MarionetteTestCase): + def test_by_unknown_number(self): + self.assertEqual(errors.MarionetteException, errors.lookup(123456)) + + def test_by_known_string(self): + self.assertEqual( + errors.NoSuchElementException, errors.lookup("no such element") + ) + + def test_by_unknown_string(self): + self.assertEqual(errors.MarionetteException, errors.lookup("barbera")) + + def test_by_known_unicode_string(self): + self.assertEqual( + errors.NoSuchElementException, errors.lookup("no such element") + ) + + +class TestAllErrors(marionette_test.MarionetteTestCase): + def test_properties(self): + for exc in errors.es_: + self.assertTrue( + hasattr(exc, "status"), "expected exception to have attribute `status'" + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_execute_async_script.py b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_async_script.py new file mode 100644 index 0000000000..49f68f7b94 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_async_script.py @@ -0,0 +1,240 @@ +import os + +from marionette_driver.errors import ( + JavascriptException, + NoAlertPresentException, + ScriptTimeoutException, +) +from marionette_driver.marionette import Alert +from marionette_driver.wait import Wait + +from marionette_harness import MarionetteTestCase + + +class TestExecuteAsyncContent(MarionetteTestCase): + def setUp(self): + super(TestExecuteAsyncContent, self).setUp() + self.marionette.timeout.script = 1 + + def tearDown(self): + if self.alert_present(): + alert = self.marionette.switch_to_alert() + alert.dismiss() + self.wait_for_alert_closed() + + def alert_present(self): + try: + Alert(self.marionette).text + return True + except NoAlertPresentException: + return False + + def wait_for_alert_closed(self, timeout=None): + Wait(self.marionette, timeout=timeout).until(lambda _: not self.alert_present()) + + def test_execute_async_simple(self): + self.assertEqual( + 1, self.marionette.execute_async_script("arguments[arguments.length-1](1);") + ) + + def test_execute_async_ours(self): + self.assertEqual(1, self.marionette.execute_async_script("arguments[0](1);")) + + def test_script_timeout_error(self): + with self.assertRaisesRegexp(ScriptTimeoutException, "Timed out after 100 ms"): + self.marionette.execute_async_script("var x = 1;", script_timeout=100) + + def test_script_timeout_reset_after_timeout_error(self): + script_timeout = self.marionette.timeout.script + with self.assertRaises(ScriptTimeoutException): + self.marionette.execute_async_script("var x = 1;", script_timeout=100) + self.assertEqual(self.marionette.timeout.script, script_timeout) + + def test_script_timeout_no_timeout_error(self): + self.assertTrue( + self.marionette.execute_async_script( + """ + var callback = arguments[arguments.length - 1]; + setTimeout(function() { callback(true); }, 500); + """, + script_timeout=1000, + ) + ) + + def test_no_timeout(self): + self.marionette.timeout.script = 10 + self.assertTrue( + self.marionette.execute_async_script( + """ + var callback = arguments[arguments.length - 1]; + setTimeout(function() { callback(true); }, 500); + """ + ) + ) + + def test_execute_async_unload(self): + self.marionette.timeout.script = 5 + unload = """ + window.location.href = "about:blank"; + """ + self.assertRaises( + JavascriptException, self.marionette.execute_async_script, unload + ) + + def test_check_window(self): + self.assertTrue( + self.marionette.execute_async_script( + "arguments[0](window != null && window != undefined);" + ) + ) + + def test_same_context(self): + var1 = "testing" + self.assertEqual( + self.marionette.execute_script( + """ + this.testvar = '{}'; + return this.testvar; + """.format( + var1 + ) + ), + var1, + ) + self.assertEqual( + self.marionette.execute_async_script( + "arguments[0](this.testvar);", new_sandbox=False + ), + var1, + ) + + def test_execute_no_return(self): + self.assertEqual(self.marionette.execute_async_script("arguments[0]()"), None) + + def test_execute_js_exception(self): + try: + self.marionette.execute_async_script( + """ + let a = 1; + foo(bar); + """ + ) + self.fail() + except JavascriptException as e: + self.assertIsNotNone(e.stacktrace) + self.assertIn( + os.path.relpath(__file__.replace(".pyc", ".py")), e.stacktrace + ) + + def test_execute_async_js_exception(self): + try: + self.marionette.execute_async_script( + """ + let [resolve] = arguments; + resolve(foo()); + """ + ) + self.fail() + except JavascriptException as e: + self.assertIsNotNone(e.stacktrace) + self.assertIn( + os.path.relpath(__file__.replace(".pyc", ".py")), e.stacktrace + ) + + def test_script_finished(self): + self.assertTrue( + self.marionette.execute_async_script( + """ + arguments[0](true); + """ + ) + ) + + def test_execute_permission(self): + self.assertRaises( + JavascriptException, + self.marionette.execute_async_script, + """ +let prefs = Components.classes["@mozilla.org/preferences-service;1"] + .getService(Components.interfaces.nsIPrefBranch); +arguments[0](4); +""", + ) + + def test_sandbox_reuse(self): + # Sandboxes between `execute_script()` invocations are shared. + self.marionette.execute_async_script( + "this.foobar = [23, 42];" "arguments[0]();" + ) + self.assertEqual( + self.marionette.execute_async_script( + "arguments[0](this.foobar);", new_sandbox=False + ), + [23, 42], + ) + + def test_sandbox_refresh_arguments(self): + self.marionette.execute_async_script( + "this.foobar = [arguments[0], arguments[1]];" + "let resolve = " + "arguments[arguments.length - 1];" + "resolve();", + script_args=[23, 42], + ) + self.assertEqual( + self.marionette.execute_async_script( + "arguments[0](this.foobar);", new_sandbox=False + ), + [23, 42], + ) + + # Functions defined in higher privilege scopes, such as the privileged + # JSWindowActor child runs in, cannot be accessed from + # content. This tests that it is possible to introspect the objects on + # `arguments` without getting permission defined errors. This is made + # possible because the last argument is always the callback/complete + # function. + # + # See bug 1290966. + def test_introspection_of_arguments(self): + self.marionette.execute_async_script( + "arguments[0].cheese; __webDriverCallback();", script_args=[], sandbox=None + ) + + def test_return_value_on_alert(self): + res = self.marionette.execute_async_script("alert()") + self.assertIsNone(res) + + +class TestExecuteAsyncChrome(TestExecuteAsyncContent): + def setUp(self): + super(TestExecuteAsyncChrome, self).setUp() + self.marionette.set_context("chrome") + + def test_execute_async_unload(self): + pass + + def test_execute_permission(self): + self.assertEqual( + 5, + self.marionette.execute_async_script( + """ + var c = Components.classes; + arguments[0](5); + """ + ), + ) + + def test_execute_async_js_exception(self): + # Javascript exceptions are not propagated in chrome code + self.marionette.timeout.script = 0.2 + with self.assertRaises(ScriptTimeoutException): + self.marionette.execute_async_script( + """ + var callback = arguments[arguments.length - 1]; + setTimeout(function() { callback(foo()); }, 50); + """ + ) + + def test_return_value_on_alert(self): + pass diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_execute_isolate.py b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_isolate.py new file mode 100644 index 0000000000..d60e2c062e --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_isolate.py @@ -0,0 +1,46 @@ +# 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_driver.errors import ScriptTimeoutException + +from marionette_harness import MarionetteTestCase + + +class TestExecuteIsolationContent(MarionetteTestCase): + def setUp(self): + super(TestExecuteIsolationContent, self).setUp() + self.content = True + + def test_execute_async_isolate(self): + # Results from one execute call that has timed out should not + # contaminate a future call. + multiplier = "*3" if self.content else "*1" + self.marionette.timeout.script = 0.5 + self.assertRaises( + ScriptTimeoutException, + self.marionette.execute_async_script, + ( + "setTimeout(function() {{ arguments[0](5{}); }}, 3000);".format( + multiplier + ) + ), + ) + + self.marionette.timeout.script = 6 + result = self.marionette.execute_async_script( + """ + let [resolve] = arguments; + setTimeout(function() {{ resolve(10{}); }}, 5000); + """.format( + multiplier + ) + ) + self.assertEqual(result, 30 if self.content else 10) + + +class TestExecuteIsolationChrome(TestExecuteIsolationContent): + def setUp(self): + super(TestExecuteIsolationChrome, self).setUp() + self.marionette.set_context("chrome") + self.content = False diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_execute_sandboxes.py b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_sandboxes.py new file mode 100644 index 0000000000..5c089acd01 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_sandboxes.py @@ -0,0 +1,86 @@ +# 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_driver.errors import JavascriptException + +from marionette_harness import MarionetteTestCase + + +class TestExecuteSandboxes(MarionetteTestCase): + def setUp(self): + super(TestExecuteSandboxes, self).setUp() + + def test_execute_system_sandbox(self): + # Test that "system" sandbox has elevated privileges in execute_script + result = self.marionette.execute_script( + "return Components.interfaces.nsIPermissionManager.ALLOW_ACTION", + sandbox="system", + ) + self.assertEqual(result, 1) + + def test_execute_async_system_sandbox(self): + # Test that "system" sandbox has elevated privileges in + # execute_async_script. + result = self.marionette.execute_async_script( + """ + let result = Ci.nsIPermissionManager.ALLOW_ACTION; + arguments[0](result); + """, + sandbox="system", + ) + self.assertEqual(result, 1) + + def test_execute_switch_sandboxes(self): + # Test that sandboxes are retained when switching between them + # for execute_script. + self.marionette.execute_script("foo = 1", sandbox="1") + self.marionette.execute_script("foo = 2", sandbox="2") + foo = self.marionette.execute_script( + "return foo", sandbox="1", new_sandbox=False + ) + self.assertEqual(foo, 1) + foo = self.marionette.execute_script( + "return foo", sandbox="2", new_sandbox=False + ) + self.assertEqual(foo, 2) + + def test_execute_new_sandbox(self): + # test that clearing a sandbox does not affect other sandboxes + self.marionette.execute_script("foo = 1", sandbox="1") + self.marionette.execute_script("foo = 2", sandbox="2") + + # deprecate sandbox 1 by asking explicitly for a fresh one + with self.assertRaises(JavascriptException): + self.marionette.execute_script( + """ + return foo + """, + sandbox="1", + new_sandbox=True, + ) + + foo = self.marionette.execute_script( + "return foo", sandbox="2", new_sandbox=False + ) + self.assertEqual(foo, 2) + + def test_execute_async_switch_sandboxes(self): + # Test that sandboxes are retained when switching between them + # for execute_async_script. + self.marionette.execute_async_script("foo = 1; arguments[0]();", sandbox="1") + self.marionette.execute_async_script("foo = 2; arguments[0]();", sandbox="2") + foo = self.marionette.execute_async_script( + "arguments[0](foo);", sandbox="1", new_sandbox=False + ) + self.assertEqual(foo, 1) + foo = self.marionette.execute_async_script( + "arguments[0](foo);", sandbox="2", new_sandbox=False + ) + self.assertEqual(foo, 2) + + +class TestExecuteSandboxesChrome(TestExecuteSandboxes): + def setUp(self): + super(TestExecuteSandboxesChrome, self).setUp() + self.marionette.set_context("chrome") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_execute_script.py b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_script.py new file mode 100644 index 0000000000..79a6185d65 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_script.py @@ -0,0 +1,569 @@ +import os + +from six.moves.urllib.parse import quote + +from marionette_driver import By, errors +from marionette_driver.marionette import Alert, WebElement +from marionette_driver.wait import Wait + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +elements = inline("<p>foo</p> <p>bar</p>") + +shadow_dom = """ + <style> + custom-checkbox-element { + display:block; width:20px; height:20px; + } + </style> + <custom-checkbox-element></custom-checkbox-element> + <script> + customElements.define('custom-checkbox-element', + class extends HTMLElement { + constructor() { + super(); + this.attachShadow({mode: '%s'}).innerHTML = ` + <div><input type="checkbox"/></div> + `; + } + }); + </script>""" + + +globals = set( + [ + "atob", + "Audio", + "btoa", + "document", + "navigator", + "URL", + "window", + ] +) + + +class TestExecuteContent(MarionetteTestCase): + def alert_present(self): + try: + Alert(self.marionette).text + return True + except errors.NoAlertPresentException: + return False + + def wait_for_alert_closed(self, timeout=None): + Wait(self.marionette, timeout=timeout).until(lambda _: not self.alert_present()) + + def tearDown(self): + if self.alert_present(): + alert = self.marionette.switch_to_alert() + alert.dismiss() + self.wait_for_alert_closed() + + def assert_is_defined(self, property, sandbox="default"): + self.assertTrue( + self.marionette.execute_script( + "return typeof arguments[0] != 'undefined'", [property], sandbox=sandbox + ), + "property {} is undefined".format(property), + ) + + def assert_is_web_element(self, element): + self.assertIsInstance(element, WebElement) + + def test_return_number(self): + self.assertEqual(1, self.marionette.execute_script("return 1")) + self.assertEqual(1.5, self.marionette.execute_script("return 1.5")) + + def test_return_boolean(self): + self.assertTrue(self.marionette.execute_script("return true")) + + def test_return_string(self): + self.assertEqual("foo", self.marionette.execute_script("return 'foo'")) + + def test_return_array(self): + self.assertEqual([1, 2], self.marionette.execute_script("return [1, 2]")) + self.assertEqual( + [1.25, 1.75], self.marionette.execute_script("return [1.25, 1.75]") + ) + self.assertEqual( + [True, False], self.marionette.execute_script("return [true, false]") + ) + self.assertEqual( + ["foo", "bar"], self.marionette.execute_script("return ['foo', 'bar']") + ) + self.assertEqual( + [1, 1.5, True, "foo"], + self.marionette.execute_script("return [1, 1.5, true, 'foo']"), + ) + self.assertEqual([1, [2]], self.marionette.execute_script("return [1, [2]]")) + + def test_return_object(self): + self.assertEqual({"foo": 1}, self.marionette.execute_script("return {foo: 1}")) + self.assertEqual( + {"foo": 1.5}, self.marionette.execute_script("return {foo: 1.5}") + ) + self.assertEqual( + {"foo": True}, self.marionette.execute_script("return {foo: true}") + ) + self.assertEqual( + {"foo": "bar"}, self.marionette.execute_script("return {foo: 'bar'}") + ) + self.assertEqual( + {"foo": [1, 2]}, self.marionette.execute_script("return {foo: [1, 2]}") + ) + self.assertEqual( + {"foo": {"bar": [1, 2]}}, + self.marionette.execute_script("return {foo: {bar: [1, 2]}}"), + ) + + def test_no_return_value(self): + self.assertIsNone(self.marionette.execute_script("true")) + + def test_argument_null(self): + self.assertIsNone( + self.marionette.execute_script( + "return arguments[0]", script_args=(None,), sandbox="default" + ) + ) + self.assertIsNone( + self.marionette.execute_script( + "return arguments[0]", script_args=(None,), sandbox="system" + ) + ) + self.assertIsNone( + self.marionette.execute_script( + "return arguments[0]", script_args=(None,), sandbox=None + ) + ) + + def test_argument_number(self): + self.assertEqual(1, self.marionette.execute_script("return arguments[0]", (1,))) + self.assertEqual( + 1.5, self.marionette.execute_script("return arguments[0]", (1.5,)) + ) + + def test_argument_boolean(self): + self.assertTrue(self.marionette.execute_script("return arguments[0]", (True,))) + + def test_argument_string(self): + self.assertEqual( + "foo", self.marionette.execute_script("return arguments[0]", ("foo",)) + ) + + def test_argument_array(self): + self.assertEqual( + [1, 2], self.marionette.execute_script("return arguments[0]", ([1, 2],)) + ) + + def test_argument_object(self): + self.assertEqual( + {"foo": 1}, + self.marionette.execute_script("return arguments[0]", ({"foo": 1},)), + ) + + def test_argument_shadow_root(self): + self.marionette.navigate(inline(shadow_dom % "open")) + elem = self.marionette.find_element(By.TAG_NAME, "custom-checkbox-element") + shadow_root = elem.shadow_root + nodeType = self.marionette.execute_script( + "return arguments[0].nodeType", script_args=(shadow_root,) + ) + self.assertEqual(nodeType, 11) + + def test_argument_web_element(self): + self.marionette.navigate(elements) + elem = self.marionette.find_element(By.TAG_NAME, "p") + nodeType = self.marionette.execute_script( + "return arguments[0].nodeType", script_args=(elem,) + ) + self.assertEqual(nodeType, 1) + + def test_default_sandbox_globals(self): + for property in globals: + self.assert_is_defined(property, sandbox="default") + + self.assert_is_defined("Components") + self.assert_is_defined("window.wrappedJSObject") + + def test_system_globals(self): + for property in globals: + self.assert_is_defined(property, sandbox="system") + + self.assert_is_defined("Components", sandbox="system") + self.assert_is_defined("window.wrappedJSObject", sandbox="system") + + def test_mutable_sandbox_globals(self): + for property in globals: + self.assert_is_defined(property, sandbox=None) + + # Components is there, but will be removed soon + self.assert_is_defined("Components", sandbox=None) + # wrappedJSObject is always there in sandboxes + self.assert_is_defined("window.wrappedJSObject", sandbox=None) + + def test_exception(self): + self.assertRaises( + errors.JavascriptException, self.marionette.execute_script, "return foo" + ) + + def test_stacktrace(self): + with self.assertRaises(errors.JavascriptException) as cm: + self.marionette.execute_script("return b") + + # by default execute_script pass the name of the python file + self.assertIn( + os.path.relpath(__file__.replace(".pyc", ".py")), cm.exception.stacktrace + ) + self.assertIn("b is not defined", str(cm.exception)) + + def test_permission(self): + for sandbox in ["default", None]: + with self.assertRaises(errors.JavascriptException): + self.marionette.execute_script( + "Components.classes['@mozilla.org/preferences-service;1']" + ) + + def test_return_web_element(self): + self.marionette.navigate(elements) + expected = self.marionette.find_element(By.TAG_NAME, "p") + actual = self.marionette.execute_script("return document.querySelector('p')") + self.assertEqual(expected, actual) + + def test_return_web_element_array(self): + self.marionette.navigate(elements) + expected = self.marionette.find_elements(By.TAG_NAME, "p") + actual = self.marionette.execute_script( + """ + let els = document.querySelectorAll('p') + return [els[0], els[1]]""" + ) + self.assertEqual(expected, actual) + + def test_return_web_element_nested_array(self): + self.marionette.navigate(elements) + expected = self.marionette.find_elements(By.TAG_NAME, "p") + actual = self.marionette.execute_script( + """ + let els = document.querySelectorAll('p') + return { els: [els[0], els[1]] }""" + ) + self.assertEqual(expected, actual["els"]) + + def test_return_web_element_nested_dict(self): + self.marionette.navigate(elements) + expected = self.marionette.find_element(By.TAG_NAME, "p") + actual = self.marionette.execute_script( + """ + let el = document.querySelector('p') + return { path: { to: { el } } }""" + ) + self.assertEqual(expected, actual["path"]["to"]["el"]) + + # Bug 938228 identifies a problem with unmarshaling NodeList + # objects from the DOM. document.querySelectorAll returns this + # construct. + def test_return_web_element_nodelist(self): + self.marionette.navigate(elements) + expected = self.marionette.find_elements(By.TAG_NAME, "p") + actual = self.marionette.execute_script("return document.querySelectorAll('p')") + self.assertEqual(expected, actual) + + def test_sandbox_reuse(self): + # Sandboxes between `execute_script()` invocations are shared. + self.marionette.execute_script("this.foobar = [23, 42];") + self.assertEqual( + self.marionette.execute_script("return this.foobar;", new_sandbox=False), + [23, 42], + ) + + def test_sandbox_refresh_arguments(self): + self.marionette.execute_script( + "this.foobar = [arguments[0], arguments[1]]", [23, 42] + ) + self.assertEqual( + self.marionette.execute_script("return this.foobar", new_sandbox=False), + [23, 42], + ) + + def test_mutable_sandbox_wrappedjsobject(self): + self.assert_is_defined("window.wrappedJSObject") + with self.assertRaises(errors.JavascriptException): + self.marionette.execute_script( + "window.wrappedJSObject.foo = 1", sandbox=None + ) + + def test_default_sandbox_wrappedjsobject(self): + self.assert_is_defined("window.wrappedJSObject", sandbox="default") + + try: + self.marionette.execute_script( + "window.wrappedJSObject.foo = 4", sandbox="default" + ) + self.assertEqual( + self.marionette.execute_script( + "return window.wrappedJSObject.foo", sandbox="default" + ), + 4, + ) + finally: + self.marionette.execute_script( + "delete window.wrappedJSObject.foo", sandbox="default" + ) + + def test_system_sandbox_wrappedjsobject(self): + self.assert_is_defined("window.wrappedJSObject", sandbox="system") + + self.marionette.execute_script( + "window.wrappedJSObject.foo = 4", sandbox="system" + ) + self.assertEqual( + self.marionette.execute_script( + "return window.wrappedJSObject.foo", sandbox="system" + ), + 4, + ) + + def test_system_dead_object(self): + self.assert_is_defined("window.wrappedJSObject", sandbox="system") + + self.marionette.execute_script( + "window.wrappedJSObject.foo = function() { return 'yo' }", sandbox="system" + ) + self.marionette.execute_script( + "dump(window.wrappedJSObject.foo)", sandbox="system" + ) + + self.marionette.execute_script( + "window.wrappedJSObject.foo = function() { return 'yolo' }", + sandbox="system", + ) + typ = self.marionette.execute_script( + "return typeof window.wrappedJSObject.foo", sandbox="system" + ) + self.assertEqual("function", typ) + obj = self.marionette.execute_script( + "return window.wrappedJSObject.foo.toString()", sandbox="system" + ) + self.assertIn("yolo", obj) + + def test_lasting_side_effects(self): + def send(script): + return self.marionette._send_message( + "WebDriver:ExecuteScript", {"script": script}, key="value" + ) + + send("window.foo = 1") + foo = send("return window.foo") + self.assertEqual(1, foo) + + for property in globals: + exists = send("return typeof {} != 'undefined'".format(property)) + self.assertTrue(exists, "property {} is undefined".format(property)) + + self.assertTrue( + send( + """ + return (typeof Components == 'undefined') || + (typeof Components.utils == 'undefined') + """ + ) + ) + self.assertTrue(send("return typeof window.wrappedJSObject == 'undefined'")) + + def test_no_callback(self): + self.assertTrue( + self.marionette.execute_script("return typeof arguments[0] == 'undefined'") + ) + + def test_window_set_timeout_is_not_cancelled(self): + def content_timeout_triggered(mn): + return mn.execute_script("return window.n", sandbox=None) > 0 + + # subsequent call to execute_script after this + # should not cancel the setTimeout event + self.marionette.navigate( + inline( + """ + <script> + window.n = 0; + setTimeout(() => ++window.n, 4000); + </script>""" + ) + ) + + # as debug builds are inherently slow, + # we need to assert the event did not already fire + self.assertEqual( + 0, + self.marionette.execute_script("return window.n", sandbox=None), + "setTimeout already fired", + ) + + # if event was cancelled, this will time out + Wait(self.marionette, timeout=8).until( + content_timeout_triggered, + message="Scheduled setTimeout event was cancelled by call to execute_script", + ) + + def test_access_chrome_objects_in_event_listeners(self): + # sandbox.window.addEventListener/removeEventListener + # is used by Marionette for installing the unloadHandler which + # is used to return an error when a document is unloaded during + # script execution. + # + # Certain web frameworks, notably Angular, override + # window.addEventListener/removeEventListener and introspects + # objects passed to them. If these objects originates from chrome + # without having been cloned, a permission denied error is thrown + # as part of the security precautions put in place by the sandbox. + + # addEventListener is called when script is injected + self.marionette.navigate( + inline( + """ + <script> + window.addEventListener = (event, listener) => listener.toString(); + </script> + """ + ) + ) + self.marionette.execute_script("", sandbox=None) + + # removeEventListener is called when sandbox is unloaded + self.marionette.navigate( + inline( + """ + <script> + window.removeEventListener = (event, listener) => listener.toString(); + </script> + """ + ) + ) + self.marionette.execute_script("", sandbox=None) + + def test_access_global_objects_from_chrome(self): + # test inspection of arguments + self.marionette.execute_script("__webDriverArguments.toString()") + + def test_toJSON(self): + foo = self.marionette.execute_script( + """ + return { + toJSON () { + return "foo"; + } + } + """, + sandbox=None, + ) + self.assertEqual("foo", foo) + + def test_unsafe_toJSON(self): + el = self.marionette.execute_script( + """ + return { + toJSON () { + return document.documentElement; + } + } + """, + sandbox=None, + ) + self.assert_is_web_element(el) + self.assertEqual(el, self.marionette.find_element(By.CSS_SELECTOR, ":root")) + + def test_comment_in_last_line(self): + self.marionette.execute_script(" // comment ") + + def test_return_value_on_alert(self): + res = self.marionette.execute_script("alert()") + self.assertIsNone(res) + + +class TestExecuteChrome(WindowManagerMixin, TestExecuteContent): + def setUp(self): + super(TestExecuteChrome, self).setUp() + + self.marionette.set_context("chrome") + win = self.open_chrome_window("chrome://remote/content/marionette/test.xhtml") + self.marionette.switch_to_window(win) + + def tearDown(self): + self.close_all_windows() + + super(TestExecuteChrome, self).tearDown() + + def test_permission(self): + self.marionette.execute_script( + "Components.classes['@mozilla.org/preferences-service;1']" + ) + + def test_unmarshal_element_collection(self): + expected = self.marionette.find_elements(By.TAG_NAME, "input") + actual = self.marionette.execute_script( + "return document.querySelectorAll('input')" + ) + self.assertTrue(len(expected) > 0) + self.assertEqual(expected, actual) + + def test_argument_shadow_root(self): + pass + + def test_argument_web_element(self): + elem = self.marionette.find_element(By.TAG_NAME, "input") + nodeType = self.marionette.execute_script( + "return arguments[0].nodeType", script_args=(elem,) + ) + self.assertEqual(nodeType, 1) + + def test_async_script_timeout(self): + with self.assertRaises(errors.ScriptTimeoutException): + self.marionette.execute_async_script( + """ + var cb = arguments[arguments.length - 1]; + setTimeout(function() { cb() }, 2500); + """, + script_timeout=100, + ) + + def test_lasting_side_effects(self): + pass + + def test_return_web_element(self): + pass + + def test_return_web_element_array(self): + pass + + def test_return_web_element_nested_array(self): + pass + + def test_return_web_element_nested_dict(self): + pass + + def test_return_web_element_nodelist(self): + pass + + def test_window_set_timeout_is_not_cancelled(self): + pass + + def test_mutable_sandbox_wrappedjsobject(self): + pass + + def test_default_sandbox_wrappedjsobject(self): + pass + + def test_system_sandbox_wrappedjsobject(self): + pass + + def test_access_chrome_objects_in_event_listeners(self): + pass + + def test_return_value_on_alert(self): + pass diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_expected.py b/testing/marionette/harness/marionette_harness/tests/unit/test_expected.py new file mode 100644 index 0000000000..4e22e31e83 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_expected.py @@ -0,0 +1,233 @@ +# 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 six.moves.urllib.parse import quote + +from marionette_driver import expected +from marionette_driver.by import By + +from marionette_harness import marionette_test + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +static_element = inline("""<p>foo</p>""") +static_elements = static_element + static_element + +remove_element_by_tag_name = """var el = document.getElementsByTagName('{}')[0]; + document.getElementsByTagName("body")[0].remove(el);""" + +hidden_element = inline("<p style='display: none'>hidden</p>") + +selected_element = inline("<option selected>selected</option>") +unselected_element = inline("<option>unselected</option>") + +enabled_element = inline("<input>") +disabled_element = inline("<input disabled>") + + +def no_such_element(marionette): + return marionette.find_element(By.ID, "nosuchelement") + + +def no_such_elements(marionette): + return marionette.find_elements(By.ID, "nosuchelement") + + +def p(marionette): + return marionette.find_element(By.TAG_NAME, "p") + + +def ps(marionette): + return marionette.find_elements(By.TAG_NAME, "p") + + +class TestExpected(marionette_test.MarionetteTestCase): + def test_element_present_func(self): + self.marionette.navigate(static_element) + el = expected.element_present(p)(self.marionette) + self.assertIsNotNone(el) + + def test_element_present_locator(self): + self.marionette.navigate(static_element) + el = expected.element_present(By.TAG_NAME, "p")(self.marionette) + self.assertIsNotNone(el) + + def test_element_present_not_present(self): + r = expected.element_present(no_such_element)(self.marionette) + self.assertIsInstance(r, bool) + self.assertFalse(r) + + def test_element_not_present_func(self): + r = expected.element_not_present(no_such_element)(self.marionette) + self.assertIsInstance(r, bool) + self.assertTrue(r) + + def test_element_not_present_locator(self): + r = expected.element_not_present(By.ID, "nosuchelement")(self.marionette) + self.assertIsInstance(r, bool) + self.assertTrue(r) + + def test_element_not_present_is_present(self): + self.marionette.navigate(static_element) + r = expected.element_not_present(p)(self.marionette) + self.assertIsInstance(r, bool) + self.assertFalse(r) + + def test_element_stale(self): + self.marionette.navigate(static_element) + el = self.marionette.find_element(By.TAG_NAME, "p") + self.assertIsNotNone(el) + self.marionette.execute_script(remove_element_by_tag_name.format("p")) + r = expected.element_stale(el)(self.marionette) + self.assertTrue(r) + + def test_element_stale_is_not_stale(self): + self.marionette.navigate(static_element) + el = self.marionette.find_element(By.TAG_NAME, "p") + r = expected.element_stale(el)(self.marionette) + self.assertFalse(r) + + def test_elements_present_func(self): + self.marionette.navigate(static_elements) + els = expected.elements_present(ps)(self.marionette) + self.assertEqual(len(els), 2) + + def test_elements_present_locator(self): + self.marionette.navigate(static_elements) + els = expected.elements_present(By.TAG_NAME, "p")(self.marionette) + self.assertEqual(len(els), 2) + + def test_elements_present_not_present(self): + r = expected.elements_present(no_such_elements)(self.marionette) + self.assertEqual(r, []) + + def test_elements_not_present_func(self): + r = expected.element_not_present(no_such_elements)(self.marionette) + self.assertIsInstance(r, bool) + self.assertTrue(r) + + def test_elements_not_present_locator(self): + r = expected.element_not_present(By.ID, "nosuchelement")(self.marionette) + self.assertIsInstance(r, bool) + self.assertTrue(r) + + def test_elements_not_present_is_present(self): + self.marionette.navigate(static_elements) + r = expected.elements_not_present(ps)(self.marionette) + self.assertIsInstance(r, bool) + self.assertFalse(r) + + def test_element_displayed(self): + self.marionette.navigate(static_element) + el = self.marionette.find_element(By.TAG_NAME, "p") + visible = expected.element_displayed(el)(self.marionette) + self.assertTrue(visible) + + def test_element_displayed_locator(self): + self.marionette.navigate(static_element) + visible = expected.element_displayed(By.TAG_NAME, "p")(self.marionette) + self.assertTrue(visible) + + def test_element_displayed_when_hidden(self): + self.marionette.navigate(hidden_element) + el = self.marionette.find_element(By.TAG_NAME, "p") + visible = expected.element_displayed(el)(self.marionette) + self.assertFalse(visible) + + def test_element_displayed_when_hidden_locator(self): + self.marionette.navigate(hidden_element) + visible = expected.element_displayed(By.TAG_NAME, "p")(self.marionette) + self.assertFalse(visible) + + def test_element_displayed_when_not_present(self): + self.marionette.navigate("about:blank") + visible = expected.element_displayed(By.TAG_NAME, "p")(self.marionette) + self.assertFalse(visible) + + def test_element_displayed_when_stale_element(self): + self.marionette.navigate(static_element) + el = self.marionette.find_element(By.TAG_NAME, "p") + self.marionette.execute_script("arguments[0].remove()", [el]) + missing = expected.element_displayed(el)(self.marionette) + self.assertFalse(missing) + + def test_element_not_displayed(self): + self.marionette.navigate(hidden_element) + el = self.marionette.find_element(By.TAG_NAME, "p") + hidden = expected.element_not_displayed(el)(self.marionette) + self.assertTrue(hidden) + + def test_element_not_displayed_locator(self): + self.marionette.navigate(hidden_element) + hidden = expected.element_not_displayed(By.TAG_NAME, "p")(self.marionette) + self.assertTrue(hidden) + + def test_element_not_displayed_when_visible(self): + self.marionette.navigate(static_element) + el = self.marionette.find_element(By.TAG_NAME, "p") + hidden = expected.element_not_displayed(el)(self.marionette) + self.assertFalse(hidden) + + def test_element_not_displayed_when_visible_locator(self): + self.marionette.navigate(static_element) + hidden = expected.element_not_displayed(By.TAG_NAME, "p")(self.marionette) + self.assertFalse(hidden) + + def test_element_not_displayed_when_stale_element(self): + self.marionette.navigate(static_element) + el = self.marionette.find_element(By.TAG_NAME, "p") + self.marionette.execute_script("arguments[0].remove()", [el]) + missing = expected.element_not_displayed(el)(self.marionette) + self.assertTrue(missing) + + def test_element_selected(self): + self.marionette.navigate(selected_element) + el = self.marionette.find_element(By.TAG_NAME, "option") + selected = expected.element_selected(el)(self.marionette) + self.assertTrue(selected) + + def test_element_selected_when_not_selected(self): + self.marionette.navigate(unselected_element) + el = self.marionette.find_element(By.TAG_NAME, "option") + unselected = expected.element_selected(el)(self.marionette) + self.assertFalse(unselected) + + def test_element_not_selected(self): + self.marionette.navigate(unselected_element) + el = self.marionette.find_element(By.TAG_NAME, "option") + unselected = expected.element_not_selected(el)(self.marionette) + self.assertTrue(unselected) + + def test_element_not_selected_when_selected(self): + self.marionette.navigate(selected_element) + el = self.marionette.find_element(By.TAG_NAME, "option") + selected = expected.element_not_selected(el)(self.marionette) + self.assertFalse(selected) + + def test_element_enabled(self): + self.marionette.navigate(enabled_element) + el = self.marionette.find_element(By.TAG_NAME, "input") + enabled = expected.element_enabled(el)(self.marionette) + self.assertTrue(enabled) + + def test_element_enabled_when_disabled(self): + self.marionette.navigate(disabled_element) + el = self.marionette.find_element(By.TAG_NAME, "input") + disabled = expected.element_enabled(el)(self.marionette) + self.assertFalse(disabled) + + def test_element_not_enabled(self): + self.marionette.navigate(disabled_element) + el = self.marionette.find_element(By.TAG_NAME, "input") + disabled = expected.element_not_enabled(el)(self.marionette) + self.assertTrue(disabled) + + def test_element_not_enabled_when_enabled(self): + self.marionette.navigate(enabled_element) + el = self.marionette.find_element(By.TAG_NAME, "input") + enabled = expected.element_not_enabled(el)(self.marionette) + self.assertFalse(enabled) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_expectedfail.py b/testing/marionette/harness/marionette_harness/tests/unit/test_expectedfail.py new file mode 100644 index 0000000000..e4d3fc499e --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_expectedfail.py @@ -0,0 +1,11 @@ +# 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_harness import MarionetteTestCase + + +class TestFail(MarionetteTestCase): + def test_fails(self): + # this test is supposed to fail! + self.assertEqual(True, False) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_file_upload.py b/testing/marionette/harness/marionette_harness/tests/unit/test_file_upload.py new file mode 100644 index 0000000000..d2ed2a8731 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_file_upload.py @@ -0,0 +1,169 @@ +# 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 contextlib + +from tempfile import NamedTemporaryFile as tempfile + +import six +from six.moves.urllib.parse import quote + +from marionette_driver import By, errors, expected +from marionette_driver.wait import Wait +from marionette_harness import MarionetteTestCase, skip + + +single = "data:text/html,{}".format(quote("<input type=file>")) +multiple = "data:text/html,{}".format(quote("<input type=file multiple>")) +upload = lambda url: "data:text/html,{}".format( + quote( + """ + <form action='{}' method=post enctype='multipart/form-data'> + <input type=file> + <input type=submit> + </form>""".format( + url + ) + ) +) + + +class TestFileUpload(MarionetteTestCase): + def test_sets_one_file(self): + self.marionette.navigate(single) + input = self.input + + exp = None + with tempfile() as f: + input.send_keys(f.name) + exp = [f.name] + + files = self.get_file_names(input) + self.assertEqual(len(files), 1) + self.assertFileNamesEqual(files, exp) + + def test_sets_multiple_files(self): + self.marionette.navigate(multiple) + input = self.input + + exp = None + with tempfile() as a, tempfile() as b: + input.send_keys(a.name) + input.send_keys(b.name) + exp = [a.name, b.name] + + files = self.get_file_names(input) + self.assertEqual(len(files), 2) + self.assertFileNamesEqual(files, exp) + + def test_sets_multiple_indentical_files(self): + self.marionette.navigate(multiple) + input = self.input + + exp = [] + with tempfile() as f: + input.send_keys(f.name) + input.send_keys(f.name) + exp = f.name + + files = self.get_file_names(input) + self.assertEqual(len(files), 2) + self.assertFileNamesEqual(files, exp) + + def test_clear_file(self): + self.marionette.navigate(single) + input = self.input + + with tempfile() as f: + input.send_keys(f.name) + + self.assertEqual(len(self.get_files(input)), 1) + input.clear() + self.assertEqual(len(self.get_files(input)), 0) + + def test_clear_files(self): + self.marionette.navigate(multiple) + input = self.input + + with tempfile() as a, tempfile() as b: + input.send_keys(a.name) + input.send_keys(b.name) + + self.assertEqual(len(self.get_files(input)), 2) + input.clear() + self.assertEqual(len(self.get_files(input)), 0) + + def test_illegal_file(self): + self.marionette.navigate(single) + with self.assertRaisesRegexp(errors.MarionetteException, "File not found"): + self.input.send_keys("rochefort") + + def test_upload(self): + self.marionette.navigate(upload(self.marionette.absolute_url("file_upload"))) + url = self.marionette.get_url() + + with tempfile() as f: + f.write(six.ensure_binary("camembert")) + f.flush() + self.input.send_keys(f.name) + self.submit.click() + + Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + lambda m: m.get_url() != url, + message="URL didn't change after submitting a file upload", + ) + self.assertIn("multipart/form-data", self.body.text) + + def test_change_event(self): + self.marionette.navigate(single) + self.marionette.execute_script( + """ + window.changeEvs = []; + let el = arguments[arguments.length - 1]; + el.addEventListener("change", ev => window.changeEvs.push(ev)); + console.log(window.changeEvs.length); + """, + script_args=(self.input,), + sandbox=None, + ) + + with tempfile() as f: + self.input.send_keys(f.name) + + nevs = self.marionette.execute_script( + "return window.changeEvs.length", sandbox=None + ) + self.assertEqual(1, nevs) + + def find_inputs(self): + return self.marionette.find_elements(By.TAG_NAME, "input") + + @property + def input(self): + return self.find_inputs()[0] + + @property + def submit(self): + return self.find_inputs()[1] + + @property + def body(self): + return Wait(self.marionette).until( + expected.element_present(By.TAG_NAME, "body") + ) + + def get_file_names(self, el): + fl = self.get_files(el) + return [f["name"] for f in fl] + + def get_files(self, el): + return self.marionette.execute_script( + "return arguments[0].files", script_args=[el] + ) + + def assertFileNamesEqual(self, act, exp): + # File array returned from browser doesn't contain full path names, + # this cuts off the path of the expected files. + filenames = [f.rsplit("/", 0)[-1] for f in act] + self.assertListEqual(filenames, act) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_findelement.py b/testing/marionette/harness/marionette_harness/tests/unit/test_findelement.py new file mode 100644 index 0000000000..3718d6bc6d --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_findelement.py @@ -0,0 +1,479 @@ +# 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 re + +from six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_driver.errors import NoSuchElementException, InvalidSelectorException +from marionette_driver.marionette import WebElement + +from marionette_harness import MarionetteTestCase, skip + + +def inline(doc, doctype="html"): + if doctype == "html": + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + elif doctype == "xhtml": + return "data:application/xhtml+xml,{}".format( + quote( + r"""<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> + <head> + <title>XHTML might be the future</title> + </head> + + <body> + {} + </body> +</html>""".format( + doc + ) + ) + ) + + +id_html = inline("<p id=foo></p>", doctype="html") +id_xhtml = inline('<p id="foo"></p>', doctype="xhtml") +parent_child_html = inline("<div id=parent><p id=child></p></div>", doctype="html") +parent_child_xhtml = inline( + '<div id="parent"><p id="child"></p></div>', doctype="xhtml" +) +children_html = inline("<div><p>foo <p>bar</div>", doctype="html") +children_xhtml = inline("<div><p>foo</p> <p>bar</p></div>", doctype="xhtml") +class_html = inline("<p class='foo bar'>", doctype="html") +class_xhtml = inline('<p class="foo bar"></p>', doctype="xhtml") +name_html = inline("<p name=foo>", doctype="html") +name_xhtml = inline('<p name="foo"></p>', doctype="xhtml") + + +class TestFindElementHTML(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + self.marionette.timeout.implicit = 0 + + def test_id(self): + self.marionette.navigate(id_html) + expected = self.marionette.execute_script("return document.querySelector('p')") + found = self.marionette.find_element(By.ID, "foo") + self.assertIsInstance(found, WebElement) + self.assertEqual(found, expected) + + def test_child_element(self): + self.marionette.navigate(parent_child_html) + parent = self.marionette.find_element(By.ID, "parent") + child = self.marionette.find_element(By.ID, "child") + found = parent.find_element(By.TAG_NAME, "p") + self.assertEqual(found.tag_name, "p") + self.assertIsInstance(found, WebElement) + self.assertEqual(child, found) + + def test_tag_name(self): + self.marionette.navigate(children_html) + el = self.marionette.execute_script("return document.querySelector('p')") + found = self.marionette.find_element(By.TAG_NAME, "p") + self.assertIsInstance(found, WebElement) + self.assertEqual(el, found) + + def test_class_name(self): + self.marionette.navigate(class_html) + el = self.marionette.execute_script("return document.querySelector('.foo')") + found = self.marionette.find_element(By.CLASS_NAME, "foo") + self.assertIsInstance(found, WebElement) + self.assertEqual(el, found) + + def test_by_name(self): + self.marionette.navigate(name_html) + el = self.marionette.execute_script( + "return document.querySelector('[name=foo]')" + ) + found = self.marionette.find_element(By.NAME, "foo") + self.assertIsInstance(found, WebElement) + self.assertEqual(el, found) + + def test_css_selector(self): + self.marionette.navigate(children_html) + el = self.marionette.execute_script("return document.querySelector('p')") + found = self.marionette.find_element(By.CSS_SELECTOR, "p") + self.assertIsInstance(found, WebElement) + self.assertEqual(el, found) + + def test_invalid_css_selector_should_throw(self): + with self.assertRaises(InvalidSelectorException): + self.marionette.find_element(By.CSS_SELECTOR, "#") + + def test_xpath(self): + self.marionette.navigate(id_html) + el = self.marionette.execute_script("return document.querySelector('#foo')") + found = self.marionette.find_element(By.XPATH, "id('foo')") + self.assertIsInstance(found, WebElement) + self.assertEqual(el, found) + + def test_not_found(self): + self.marionette.timeout.implicit = 0 + self.assertRaises( + NoSuchElementException, + self.marionette.find_element, + By.CLASS_NAME, + "cheese", + ) + self.assertRaises( + NoSuchElementException, + self.marionette.find_element, + By.CSS_SELECTOR, + "cheese", + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.ID, "cheese" + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.LINK_TEXT, "cheese" + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.NAME, "cheese" + ) + self.assertRaises( + NoSuchElementException, + self.marionette.find_element, + By.PARTIAL_LINK_TEXT, + "cheese", + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.TAG_NAME, "cheese" + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.XPATH, "cheese" + ) + + def test_not_found_implicit_wait(self): + self.marionette.timeout.implicit = 0.5 + self.assertRaises( + NoSuchElementException, + self.marionette.find_element, + By.CLASS_NAME, + "cheese", + ) + self.assertRaises( + NoSuchElementException, + self.marionette.find_element, + By.CSS_SELECTOR, + "cheese", + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.ID, "cheese" + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.LINK_TEXT, "cheese" + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.NAME, "cheese" + ) + self.assertRaises( + NoSuchElementException, + self.marionette.find_element, + By.PARTIAL_LINK_TEXT, + "cheese", + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.TAG_NAME, "cheese" + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.XPATH, "cheese" + ) + + def test_not_found_from_element(self): + self.marionette.timeout.implicit = 0 + self.marionette.navigate(id_html) + el = self.marionette.find_element(By.ID, "foo") + self.assertRaises( + NoSuchElementException, el.find_element, By.CLASS_NAME, "cheese" + ) + self.assertRaises( + NoSuchElementException, el.find_element, By.CSS_SELECTOR, "cheese" + ) + self.assertRaises(NoSuchElementException, el.find_element, By.ID, "cheese") + self.assertRaises( + NoSuchElementException, el.find_element, By.LINK_TEXT, "cheese" + ) + self.assertRaises(NoSuchElementException, el.find_element, By.NAME, "cheese") + self.assertRaises( + NoSuchElementException, el.find_element, By.PARTIAL_LINK_TEXT, "cheese" + ) + self.assertRaises( + NoSuchElementException, el.find_element, By.TAG_NAME, "cheese" + ) + self.assertRaises(NoSuchElementException, el.find_element, By.XPATH, "cheese") + + def test_not_found_implicit_wait_from_element(self): + self.marionette.timeout.implicit = 0.5 + self.marionette.navigate(id_html) + el = self.marionette.find_element(By.ID, "foo") + self.assertRaises( + NoSuchElementException, el.find_element, By.CLASS_NAME, "cheese" + ) + self.assertRaises( + NoSuchElementException, el.find_element, By.CSS_SELECTOR, "cheese" + ) + self.assertRaises(NoSuchElementException, el.find_element, By.ID, "cheese") + self.assertRaises( + NoSuchElementException, el.find_element, By.LINK_TEXT, "cheese" + ) + self.assertRaises(NoSuchElementException, el.find_element, By.NAME, "cheese") + self.assertRaises( + NoSuchElementException, el.find_element, By.PARTIAL_LINK_TEXT, "cheese" + ) + self.assertRaises( + NoSuchElementException, el.find_element, By.TAG_NAME, "cheese" + ) + self.assertRaises(NoSuchElementException, el.find_element, By.XPATH, "cheese") + + def test_css_selector_scope_doesnt_start_at_rootnode(self): + self.marionette.navigate(parent_child_html) + el = self.marionette.find_element(By.ID, "child") + parent = self.marionette.find_element(By.ID, "parent") + found = parent.find_element(By.CSS_SELECTOR, "p") + self.assertEqual(el, found) + + def test_unknown_selector(self): + with self.assertRaises(InvalidSelectorException): + self.marionette.find_element("foo", "bar") + + def test_invalid_xpath_selector(self): + with self.assertRaises(InvalidSelectorException): + self.marionette.find_element(By.XPATH, "count(//input)") + with self.assertRaises(InvalidSelectorException): + parent = self.marionette.execute_script("return document.documentElement") + parent.find_element(By.XPATH, "count(//input)") + + def test_invalid_css_selector(self): + with self.assertRaises(InvalidSelectorException): + self.marionette.find_element(By.CSS_SELECTOR, "") + with self.assertRaises(InvalidSelectorException): + parent = self.marionette.execute_script("return document.documentElement") + parent.find_element(By.CSS_SELECTOR, "") + + def test_finding_active_element_returns_element(self): + self.marionette.navigate(id_html) + active = self.marionette.execute_script("return document.activeElement") + self.assertEqual(active, self.marionette.get_active_element()) + + +class TestFindElementXHTML(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + self.marionette.timeout.implicit = 0 + + def test_id(self): + self.marionette.navigate(id_xhtml) + expected = self.marionette.execute_script("return document.querySelector('p')") + found = self.marionette.find_element(By.ID, "foo") + self.assertIsInstance(found, WebElement) + self.assertEqual(expected, found) + + def test_child_element(self): + self.marionette.navigate(parent_child_xhtml) + parent = self.marionette.find_element(By.ID, "parent") + child = self.marionette.find_element(By.ID, "child") + found = parent.find_element(By.TAG_NAME, "p") + self.assertEqual(found.tag_name, "p") + self.assertIsInstance(found, WebElement) + self.assertEqual(child, found) + + def test_tag_name(self): + self.marionette.navigate(children_xhtml) + el = self.marionette.execute_script("return document.querySelector('p')") + found = self.marionette.find_element(By.TAG_NAME, "p") + self.assertIsInstance(found, WebElement) + self.assertEqual(el, found) + + def test_class_name(self): + self.marionette.navigate(class_xhtml) + el = self.marionette.execute_script("return document.querySelector('.foo')") + found = self.marionette.find_element(By.CLASS_NAME, "foo") + self.assertIsInstance(found, WebElement) + self.assertEqual(el, found) + + def test_by_name(self): + self.marionette.navigate(name_xhtml) + el = self.marionette.execute_script( + "return document.querySelector('[name=foo]')" + ) + found = self.marionette.find_element(By.NAME, "foo") + self.assertIsInstance(found, WebElement) + self.assertEqual(el, found) + + def test_css_selector(self): + self.marionette.navigate(children_xhtml) + el = self.marionette.execute_script("return document.querySelector('p')") + found = self.marionette.find_element(By.CSS_SELECTOR, "p") + self.assertIsInstance(found, WebElement) + self.assertEqual(el, found) + + def test_xpath(self): + self.marionette.navigate(id_xhtml) + el = self.marionette.execute_script("return document.querySelector('#foo')") + found = self.marionette.find_element(By.XPATH, "id('foo')") + self.assertIsInstance(found, WebElement) + self.assertEqual(el, found) + + def test_css_selector_scope_does_not_start_at_rootnode(self): + self.marionette.navigate(parent_child_xhtml) + el = self.marionette.find_element(By.ID, "child") + parent = self.marionette.find_element(By.ID, "parent") + found = parent.find_element(By.CSS_SELECTOR, "p") + self.assertEqual(el, found) + + def test_active_element(self): + self.marionette.navigate(id_xhtml) + active = self.marionette.execute_script("return document.activeElement") + self.assertEqual(active, self.marionette.get_active_element()) + + +class TestFindElementsHTML(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + self.marionette.timeout.implicit = 0 + + def assertItemsIsInstance(self, items, typ): + for item in items: + self.assertIsInstance(item, typ) + + def test_child_elements(self): + self.marionette.navigate(children_html) + parent = self.marionette.find_element(By.TAG_NAME, "div") + children = self.marionette.find_elements(By.TAG_NAME, "p") + found = parent.find_elements(By.TAG_NAME, "p") + self.assertItemsIsInstance(found, WebElement) + self.assertSequenceEqual(found, children) + + def test_tag_name(self): + self.marionette.navigate(children_html) + els = self.marionette.execute_script("return document.querySelectorAll('p')") + found = self.marionette.find_elements(By.TAG_NAME, "p") + self.assertItemsIsInstance(found, WebElement) + self.assertSequenceEqual(els, found) + + def test_class_name(self): + self.marionette.navigate(class_html) + els = self.marionette.execute_script("return document.querySelectorAll('.foo')") + found = self.marionette.find_elements(By.CLASS_NAME, "foo") + self.assertItemsIsInstance(found, WebElement) + self.assertSequenceEqual(els, found) + + def test_by_name(self): + self.marionette.navigate(name_html) + els = self.marionette.execute_script( + "return document.querySelectorAll('[name=foo]')" + ) + found = self.marionette.find_elements(By.NAME, "foo") + self.assertItemsIsInstance(found, WebElement) + self.assertSequenceEqual(els, found) + + def test_css_selector(self): + self.marionette.navigate(children_html) + els = self.marionette.execute_script("return document.querySelectorAll('p')") + found = self.marionette.find_elements(By.CSS_SELECTOR, "p") + self.assertItemsIsInstance(found, WebElement) + self.assertSequenceEqual(els, found) + + def test_invalid_css_selector_should_throw(self): + with self.assertRaises(InvalidSelectorException): + self.marionette.find_elements(By.CSS_SELECTOR, "#") + + def test_xpath(self): + self.marionette.navigate(children_html) + els = self.marionette.execute_script("return document.querySelectorAll('p')") + found = self.marionette.find_elements(By.XPATH, ".//p") + self.assertItemsIsInstance(found, WebElement) + self.assertSequenceEqual(els, found) + + def test_css_selector_scope_doesnt_start_at_rootnode(self): + self.marionette.navigate(parent_child_html) + els = self.marionette.find_elements(By.ID, "child") + parent = self.marionette.find_element(By.ID, "parent") + found = parent.find_elements(By.CSS_SELECTOR, "p") + self.assertSequenceEqual(els, found) + + def test_unknown_selector(self): + with self.assertRaises(InvalidSelectorException): + self.marionette.find_elements("foo", "bar") + + def test_invalid_xpath_selector(self): + with self.assertRaises(InvalidSelectorException): + self.marionette.find_elements(By.XPATH, "count(//input)") + with self.assertRaises(InvalidSelectorException): + parent = self.marionette.execute_script("return document.documentElement") + parent.find_elements(By.XPATH, "count(//input)") + + def test_invalid_css_selector(self): + with self.assertRaises(InvalidSelectorException): + self.marionette.find_elements(By.CSS_SELECTOR, "") + with self.assertRaises(InvalidSelectorException): + parent = self.marionette.execute_script("return document.documentElement") + parent.find_elements(By.CSS_SELECTOR, "") + + +class TestFindElementsXHTML(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + self.marionette.timeout.implicit = 0 + + def assertItemsIsInstance(self, items, typ): + for item in items: + self.assertIsInstance(item, typ) + + def test_child_elements(self): + self.marionette.navigate(children_xhtml) + parent = self.marionette.find_element(By.TAG_NAME, "div") + children = self.marionette.find_elements(By.TAG_NAME, "p") + found = parent.find_elements(By.TAG_NAME, "p") + self.assertItemsIsInstance(found, WebElement) + self.assertSequenceEqual(found, children) + + def test_tag_name(self): + self.marionette.navigate(children_xhtml) + els = self.marionette.execute_script("return document.querySelectorAll('p')") + found = self.marionette.find_elements(By.TAG_NAME, "p") + self.assertItemsIsInstance(found, WebElement) + self.assertSequenceEqual(els, found) + + def test_class_name(self): + self.marionette.navigate(class_xhtml) + els = self.marionette.execute_script("return document.querySelectorAll('.foo')") + found = self.marionette.find_elements(By.CLASS_NAME, "foo") + self.assertItemsIsInstance(found, WebElement) + self.assertSequenceEqual(els, found) + + def test_by_name(self): + self.marionette.navigate(name_xhtml) + els = self.marionette.execute_script( + "return document.querySelectorAll('[name=foo]')" + ) + found = self.marionette.find_elements(By.NAME, "foo") + self.assertItemsIsInstance(found, WebElement) + self.assertSequenceEqual(els, found) + + def test_css_selector(self): + self.marionette.navigate(children_xhtml) + els = self.marionette.execute_script("return document.querySelectorAll('p')") + found = self.marionette.find_elements(By.CSS_SELECTOR, "p") + self.assertItemsIsInstance(found, WebElement) + self.assertSequenceEqual(els, found) + + @skip("XHTML namespace not yet supported") + def test_xpath(self): + self.marionette.navigate(children_xhtml) + els = self.marionette.execute_script("return document.querySelectorAll('p')") + found = self.marionette.find_elements(By.XPATH, "//xhtml:p") + self.assertItemsIsInstance(found, WebElement) + self.assertSequenceEqual(els, found) + + def test_css_selector_scope_doesnt_start_at_rootnode(self): + self.marionette.navigate(parent_child_xhtml) + els = self.marionette.find_elements(By.ID, "child") + parent = self.marionette.find_element(By.ID, "parent") + found = parent.find_elements(By.CSS_SELECTOR, "p") + self.assertSequenceEqual(els, found) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_findelement_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_findelement_chrome.py new file mode 100644 index 0000000000..eccbcf1195 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_findelement_chrome.py @@ -0,0 +1,169 @@ +# 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_driver.by import By +from marionette_driver.errors import NoSuchElementException +from marionette_driver.marionette import WebElement, WEB_ELEMENT_KEY + +from marionette_harness import MarionetteTestCase, parameterized, WindowManagerMixin + + +PAGE_XHTML = "chrome://remote/content/marionette/test_no_xul.xhtml" +PAGE_XUL = "chrome://remote/content/marionette/test.xhtml" + + +class TestElementsChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestElementsChrome, self).setUp() + + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + + super(TestElementsChrome, self).tearDown() + + @parameterized("XUL", PAGE_XUL) + @parameterized("XHTML", PAGE_XHTML) + def test_id(self, chrome_url): + win = self.open_chrome_window(chrome_url) + self.marionette.switch_to_window(win) + + el = self.marionette.execute_script( + "return window.document.getElementById('textInput');" + ) + found_el = self.marionette.find_element(By.ID, "textInput") + self.assertEqual(WebElement, type(found_el)) + self.assertEqual(WEB_ELEMENT_KEY, found_el.kind) + self.assertEqual(el, found_el) + + @parameterized("XUL", PAGE_XUL) + @parameterized("XHTML", PAGE_XHTML) + def test_that_we_can_find_elements_from_css_selectors(self, chrome_url): + win = self.open_chrome_window(chrome_url) + self.marionette.switch_to_window(win) + + el = self.marionette.execute_script( + "return window.document.getElementById('textInput');" + ) + found_el = self.marionette.find_element(By.CSS_SELECTOR, "#textInput") + self.assertEqual(WebElement, type(found_el)) + self.assertEqual(WEB_ELEMENT_KEY, found_el.kind) + self.assertEqual(el, found_el) + + @parameterized("XUL", PAGE_XUL) + @parameterized("XHTML", PAGE_XHTML) + def test_child_element(self, chrome_url): + win = self.open_chrome_window(chrome_url) + self.marionette.switch_to_window(win) + + el = self.marionette.find_element(By.ID, "textInput") + parent = self.marionette.find_element(By.ID, "things") + found_el = parent.find_element(By.TAG_NAME, "input") + self.assertEqual(WebElement, type(found_el)) + self.assertEqual(WEB_ELEMENT_KEY, found_el.kind) + self.assertEqual(el, found_el) + + @parameterized("XUL", PAGE_XUL) + @parameterized("XHTML", PAGE_XHTML) + def test_child_elements(self, chrome_url): + win = self.open_chrome_window(chrome_url) + self.marionette.switch_to_window(win) + + el = self.marionette.find_element(By.ID, "textInput3") + parent = self.marionette.find_element(By.ID, "things") + found_els = parent.find_elements(By.TAG_NAME, "input") + self.assertTrue(el.id in [found_el.id for found_el in found_els]) + + @parameterized("XUL", PAGE_XUL) + @parameterized("XHTML", PAGE_XHTML) + def test_tag_name(self, chrome_url): + win = self.open_chrome_window(chrome_url) + self.marionette.switch_to_window(win) + + el = self.marionette.execute_script( + "return window.document.getElementsByTagName('vbox')[0];" + ) + found_el = self.marionette.find_element(By.TAG_NAME, "vbox") + self.assertEqual("vbox", found_el.tag_name) + self.assertEqual(WebElement, type(found_el)) + self.assertEqual(WEB_ELEMENT_KEY, found_el.kind) + self.assertEqual(el, found_el) + + @parameterized("XUL", PAGE_XUL) + @parameterized("XHTML", PAGE_XHTML) + def test_class_name(self, chrome_url): + win = self.open_chrome_window(chrome_url) + self.marionette.switch_to_window(win) + + el = self.marionette.execute_script( + "return window.document.getElementsByClassName('asdf')[0];" + ) + found_el = self.marionette.find_element(By.CLASS_NAME, "asdf") + self.assertEqual(WebElement, type(found_el)) + self.assertEqual(WEB_ELEMENT_KEY, found_el.kind) + self.assertEqual(el, found_el) + + @parameterized("XUL", PAGE_XUL) + @parameterized("XHTML", PAGE_XHTML) + def test_xpath(self, chrome_url): + win = self.open_chrome_window(chrome_url) + self.marionette.switch_to_window(win) + + el = self.marionette.execute_script( + "return window.document.getElementById('testBox');" + ) + found_el = self.marionette.find_element(By.XPATH, "id('testBox')") + self.assertEqual(WebElement, type(found_el)) + self.assertEqual(WEB_ELEMENT_KEY, found_el.kind) + self.assertEqual(el, found_el) + + @parameterized("XUL", PAGE_XUL) + @parameterized("XHTML", PAGE_XHTML) + def test_not_found(self, chrome_url): + win = self.open_chrome_window(chrome_url) + self.marionette.switch_to_window(win) + + self.marionette.timeout.implicit = 1 + self.assertRaises( + NoSuchElementException, + self.marionette.find_element, + By.ID, + "I'm not on the page", + ) + self.marionette.timeout.implicit = 0 + self.assertRaises( + NoSuchElementException, + self.marionette.find_element, + By.ID, + "I'm not on the page", + ) + + @parameterized("XUL", PAGE_XUL) + @parameterized("XHTML", PAGE_XHTML) + def test_timeout(self, chrome_url): + win = self.open_chrome_window(chrome_url) + self.marionette.switch_to_window(win) + + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.ID, "myid" + ) + self.marionette.timeout.implicit = 4 + self.marionette.execute_script( + """ + window.setTimeout(function () { + var b = window.document.createXULElement('button'); + b.id = 'myid'; + document.getElementById('things').appendChild(b); + }, 1000); """ + ) + found_el = self.marionette.find_element(By.ID, "myid") + self.assertEqual(WebElement, type(found_el)) + self.assertEqual(WEB_ELEMENT_KEY, found_el.kind) + + self.marionette.execute_script( + """ + var elem = window.document.getElementById('things'); + elem.removeChild(window.document.getElementById('myid')); """ + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_geckoinstance.py b/testing/marionette/harness/marionette_harness/tests/unit/test_geckoinstance.py new file mode 100644 index 0000000000..3d35217bc4 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_geckoinstance.py @@ -0,0 +1,25 @@ +# 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_driver.geckoinstance import apps, GeckoInstance + +from marionette_harness import MarionetteTestCase + + +class TestGeckoInstance(MarionetteTestCase): + def test_create(self): + """Test that the correct gecko instance is determined.""" + for app in apps: + # If app has been specified we directly return the appropriate instance class + self.assertEqual(type(GeckoInstance.create(app=app, bin="n/a")), apps[app]) + + # Unknown applications and binaries should fail + self.assertRaises( + NotImplementedError, + GeckoInstance.create, + app="n/a", + bin=self.marionette.bin, + ) + self.assertRaises(NotImplementedError, GeckoInstance.create, bin="n/a") + self.assertRaises(NotImplementedError, GeckoInstance.create, bin=None) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_get_computed_label.py b/testing/marionette/harness/marionette_harness/tests/unit/test_get_computed_label.py new file mode 100644 index 0000000000..07091319c9 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_get_computed_label.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/. + +from six.moves.urllib.parse import quote + +from marionette_driver import By, errors +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestGetComputedLabel(MarionetteTestCase): + def test_can_get_computed_label(self): + self.marionette.navigate(inline("<label for=b>foo<label><input id=b>")) + computed_label = self.marionette.find_element(By.ID, "b").computed_label + self.assertEqual(computed_label, "foo") + + def test_get_computed_label_no_such_element(self): + self.marionette.navigate(inline("<div id=a>")) + element = self.marionette.find_element(By.ID, "a") + element.id = "b" + with self.assertRaises(errors.NoSuchElementException): + element.computed_label diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_get_computed_role.py b/testing/marionette/harness/marionette_harness/tests/unit/test_get_computed_role.py new file mode 100644 index 0000000000..4b16a98741 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_get_computed_role.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/. + +from six.moves.urllib.parse import quote + +from marionette_driver import By, errors +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestGetComputedRole(MarionetteTestCase): + def test_can_get_computed_role(self): + self.marionette.navigate(inline("<button id=a>btn</button>")) + computed_role = self.marionette.find_element(By.ID, "a").computed_role + self.assertEqual(computed_role, "button") + + def test_get_computed_role_no_such_element(self): + self.marionette.navigate(inline("<div id=a>")) + element = self.marionette.find_element(By.ID, "a") + element.id = "b" + with self.assertRaises(errors.NoSuchElementException): + element.computed_role diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_get_current_url_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_get_current_url_chrome.py new file mode 100644 index 0000000000..2a2c876d03 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_get_current_url_chrome.py @@ -0,0 +1,39 @@ +# 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_driver.errors import NoSuchWindowException + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestGetCurrentUrlChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestGetCurrentUrlChrome, self).setUp() + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + super(TestGetCurrentUrlChrome, self).tearDown() + + def test_browser_window(self): + url = self.marionette.absolute_url("test.html") + + with self.marionette.using_context("content"): + self.marionette.navigate(url) + self.assertEqual(self.marionette.get_url(), url) + + chrome_url = self.marionette.execute_script("return window.location.href;") + self.assertEqual(self.marionette.get_url(), chrome_url) + + def test_no_browser_window(self): + win = self.open_chrome_window("chrome://remote/content/marionette/test.xhtml") + self.marionette.switch_to_window(win) + + chrome_url = self.marionette.execute_script("return window.location.href;") + self.assertEqual(self.marionette.get_url(), chrome_url) + + # With no tabbrowser available an exception will be thrown + with self.assertRaises(NoSuchWindowException): + with self.marionette.using_context("content"): + self.marionette.get_url() diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_get_shadow_root.py b/testing/marionette/harness/marionette_harness/tests/unit/test_get_shadow_root.py new file mode 100644 index 0000000000..b8750a6c63 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_get_shadow_root.py @@ -0,0 +1,66 @@ +# 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 six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_driver.errors import ( + DetachedShadowRootException, + NoSuchShadowRootException, +) +from marionette_driver.marionette import ShadowRoot +from marionette_harness import MarionetteTestCase + +checkbox_dom = """ + <style> + custom-checkbox-element { + display:block; width:20px; height:20px; + } + </style> + <custom-checkbox-element></custom-checkbox-element> + <script> + customElements.define('custom-checkbox-element', + class extends HTMLElement { + constructor() { + super(); + this.attachShadow({mode: '%s'}).innerHTML = ` + <div><input type="checkbox"/></div> + `; + } + }); + </script>""" + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestShadowDom(MarionetteTestCase): + def setUp(self): + super(TestShadowDom, self).setUp() + + def test_can_get_open_shadow_root(self): + self.marionette.navigate(inline(checkbox_dom % "open")) + element = self.marionette.find_element( + By.CSS_SELECTOR, "custom-checkbox-element" + ) + shadow_root = element.shadow_root + assert isinstance( + shadow_root, ShadowRoot + ), "Should have received ShadowRoot but got {}".format(shadow_root) + + def test_can_get_closed_shadow_root(self): + self.marionette.navigate(inline(checkbox_dom % "closed")) + element = self.marionette.find_element( + By.CSS_SELECTOR, "custom-checkbox-element" + ) + shadow_root = element.shadow_root + assert isinstance( + shadow_root, ShadowRoot + ), "Should have received ShadowRoot but got {}".format(shadow_root) + + def test_cannot_find_shadow_root(self): + element = self.marionette.find_element(By.CSS_SELECTOR, "style") + with self.assertRaises(NoSuchShadowRootException): + element.shadow_root diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_implicit_waits.py b/testing/marionette/harness/marionette_harness/tests/unit/test_implicit_waits.py new file mode 100644 index 0000000000..954443ac30 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_implicit_waits.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/. + +from marionette_driver.by import By +from marionette_driver.errors import NoSuchElementException + +from marionette_harness import MarionetteTestCase + + +class TestImplicitWaits(MarionetteTestCase): + def test_implicitly_wait_for_single_element(self): + test_html = self.marionette.absolute_url("test_dynamic.html") + self.marionette.navigate(test_html) + add = self.marionette.find_element(By.ID, "adder") + self.marionette.timeout.implicit = 30 + add.click() + # all is well if this does not throw + self.marionette.find_element(By.ID, "box0") + + def test_implicit_wait_reaches_timeout(self): + test_html = self.marionette.absolute_url("test_dynamic.html") + self.marionette.navigate(test_html) + self.marionette.timeout.implicit = 3 + with self.assertRaises(NoSuchElementException): + self.marionette.find_element(By.ID, "box0") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_localization.py b/testing/marionette/harness/marionette_harness/tests/unit/test_localization.py new file mode 100644 index 0000000000..9bf0c1ea19 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_localization.py @@ -0,0 +1,71 @@ +# 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_driver import By +from marionette_driver.errors import ( + InvalidArgumentException, + NoSuchElementException, + UnknownException, +) +from marionette_driver.localization import L10n + +from marionette_harness import MarionetteTestCase + + +class TestL10n(MarionetteTestCase): + def setUp(self): + super(TestL10n, self).setUp() + + self.l10n = L10n(self.marionette) + + def test_localize_entity(self): + dtds = ["chrome://remote/content/marionette/test_dialog.dtd"] + value = self.l10n.localize_entity(dtds, "testDialog.title") + + self.assertEqual(value, "Test Dialog") + + def test_localize_entity_invalid_arguments(self): + dtds = ["chrome://remote/content/marionette/test_dialog.dtd"] + + self.assertRaises( + NoSuchElementException, self.l10n.localize_entity, dtds, "notExistent" + ) + self.assertRaises( + InvalidArgumentException, self.l10n.localize_entity, dtds[0], "notExistent" + ) + self.assertRaises( + InvalidArgumentException, self.l10n.localize_entity, dtds, True + ) + + def test_localize_property(self): + properties = ["chrome://remote/content/marionette/test_dialog.properties"] + + value = self.l10n.localize_property(properties, "testDialog.title") + self.assertEqual(value, "Test Dialog") + + self.assertRaises( + NoSuchElementException, + self.l10n.localize_property, + properties, + "notExistent", + ) + + def test_localize_property_invalid_arguments(self): + properties = ["chrome://global/locale/filepicker.properties"] + + self.assertRaises( + NoSuchElementException, + self.l10n.localize_property, + properties, + "notExistent", + ) + self.assertRaises( + InvalidArgumentException, + self.l10n.localize_property, + properties[0], + "notExistent", + ) + self.assertRaises( + InvalidArgumentException, self.l10n.localize_property, properties, True + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_marionette.py b/testing/marionette/harness/marionette_harness/tests/unit/test_marionette.py new file mode 100644 index 0000000000..790a802975 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_marionette.py @@ -0,0 +1,138 @@ +# 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 socket +import time + +from marionette_driver import errors +from marionette_driver.marionette import Marionette +from marionette_harness import MarionetteTestCase, run_if_manage_instance + + +class TestMarionette(MarionetteTestCase): + def test_correct_test_name(self): + """Test that the correct test name gets set.""" + expected_test_name = "{module}.py {cls}.{func}".format( + module=__name__, + cls=self.__class__.__name__, + func=self.test_correct_test_name.__name__, + ) + + self.assertIn(expected_test_name, self.marionette.test_name) + + @run_if_manage_instance("Only runnable if Marionette manages the instance") + def test_raise_for_port_non_existing_process(self): + """Test that raise_for_port doesn't run into a timeout if instance is not running.""" + self.marionette.quit() + self.assertIsNotNone(self.marionette.instance.runner.returncode) + start_time = time.time() + self.assertRaises(socket.timeout, self.marionette.raise_for_port, timeout=5) + self.assertLess(time.time() - start_time, 5) + + @run_if_manage_instance("Only runnable if Marionette manages the instance") + def test_marionette_active_port_file(self): + active_port_file = os.path.join( + self.marionette.instance.profile.profile, "MarionetteActivePort" + ) + self.assertTrue( + os.path.exists(active_port_file), "MarionetteActivePort file written" + ) + with open(active_port_file, "r") as fp: + lines = fp.readlines() + self.assertEqual(len(lines), 1, "MarionetteActivePort file contains two lines") + self.assertEqual( + int(lines[0]), + self.marionette.port, + "MarionetteActivePort file contains port", + ) + + self.marionette.quit() + self.assertFalse( + os.path.exists(active_port_file), "MarionetteActivePort file removed" + ) + + def test_single_active_session(self): + self.assertEqual(1, self.marionette.execute_script("return 1")) + + # Use a new Marionette instance for the connection attempt, while there is + # still an active session present. + marionette = Marionette(host=self.marionette.host, port=self.marionette.port) + self.assertRaises(socket.timeout, marionette.raise_for_port, timeout=1.0) + + def test_disable_enable_new_connections(self): + # Do not re-create socket if it already exists + self.marionette._send_message("Marionette:AcceptConnections", {"value": True}) + + try: + # Disabling new connections does not affect the existing one. + self.marionette._send_message( + "Marionette:AcceptConnections", {"value": False} + ) + self.assertEqual(1, self.marionette.execute_script("return 1")) + + # Delete the current active session to allow new connection attempts. + self.marionette.delete_session() + + # Use a new Marionette instance for the connection attempt, that doesn't + # handle an instance of the application to prevent a connection lost error. + marionette = Marionette( + host=self.marionette.host, port=self.marionette.port + ) + self.assertRaises(socket.timeout, marionette.raise_for_port, timeout=1.0) + + finally: + self.marionette.quit(in_app=False) + + def test_client_socket_uses_expected_socket_timeout(self): + current_socket_timeout = self.marionette.socket_timeout + + self.assertEqual(current_socket_timeout, self.marionette.client.socket_timeout) + self.assertEqual( + current_socket_timeout, + self.marionette.client._socket_context._sock.gettimeout(), + ) + + def test_application_update_disabled(self): + # Updates of the application should always be disabled by default + with self.marionette.using_context("chrome"): + update_allowed = self.marionette.execute_script( + """ + let aus = Cc['@mozilla.org/updates/update-service;1'] + .getService(Ci.nsIApplicationUpdateService); + return aus.canCheckForUpdates; + """ + ) + + self.assertFalse(update_allowed) + + +class TestContext(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + self.marionette.set_context(self.marionette.CONTEXT_CONTENT) + + def get_context(self): + return self.marionette._send_message("Marionette:GetContext", key="value") + + def set_context(self, value): + return self.marionette._send_message("Marionette:SetContext", {"value": value}) + + def test_set_context(self): + self.assertEqual(self.set_context("content"), {"value": None}) + self.assertEqual(self.set_context("chrome"), {"value": None}) + + for typ in [True, 42, [], {}, None]: + with self.assertRaises(errors.InvalidArgumentException): + self.set_context(typ) + + with self.assertRaises(errors.MarionetteException): + self.set_context("foo") + + def test_get_context(self): + self.assertEqual(self.get_context(), "content") + self.set_context("chrome") + self.assertEqual(self.get_context(), "chrome") + self.set_context("content") + self.assertEqual(self.get_context(), "content") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_modal_dialogs.py b/testing/marionette/harness/marionette_harness/tests/unit/test_modal_dialogs.py new file mode 100644 index 0000000000..e738625899 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_modal_dialogs.py @@ -0,0 +1,161 @@ +from marionette_driver.by import By +from marionette_driver.expected import element_present +from marionette_driver import errors +from marionette_driver.marionette import Alert +from marionette_driver.wait import Wait + +from marionette_harness import MarionetteTestCase, parameterized, WindowManagerMixin + + +class TestModalDialogs(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestModalDialogs, self).setUp() + self.new_tab = self.open_tab() + self.marionette.switch_to_window(self.new_tab) + + self.http_auth_pref = ( + "network.auth.non-web-content-triggered-resources-http-auth-allow" + ) + + def tearDown(self): + # Ensure to close all possible remaining tab modal dialogs + try: + while True: + alert = self.marionette.switch_to_alert() + alert.dismiss() + except errors.NoAlertPresentException: + pass + + self.close_all_tabs() + self.close_all_windows() + + super(TestModalDialogs, self).tearDown() + + @property + def alert_present(self): + try: + Alert(self.marionette).text + return True + except errors.NoAlertPresentException: + return False + + def wait_for_alert(self, timeout=None): + Wait(self.marionette, timeout=timeout).until(lambda _: self.alert_present) + + def open_custom_prompt(self, modal_type, delay=0): + browsing_context_id = self.marionette.execute_script( + """ + return window.browsingContext.id; + """, + sandbox="system", + ) + + with self.marionette.using_context("chrome"): + self.marionette.execute_script( + """ + const [ modalType, browsingContextId, delay ] = arguments; + + const modalTypes = { + 1: Services.prompt.MODAL_TYPE_CONTENT, + 2: Services.prompt.MODAL_TYPE_TAB, + 3: Services.prompt.MODAL_TYPE_WINDOW, + 4: Services.prompt.MODAL_TYPE_INTERNAL_WINDOW, + } + + window.setTimeout(() => { + Services.prompt.alertBC( + BrowsingContext.get(browsingContextId), + modalTypes[modalType], + "title", + "text" + ); + }, delay); + """, + script_args=(modal_type, browsing_context_id, delay * 1000), + ) + + @parameterized("content", 1) + @parameterized("tab", 2) + @parameterized("window", 3) + @parameterized("internal_window", 4) + def test_detect_modal_type_in_current_tab_for_type(self, type): + self.open_custom_prompt(type) + self.wait_for_alert() + + self.assertTrue(self.alert_present) + + # Restart the session to ensure we still find the formerly left-open dialog. + self.marionette.delete_session() + self.marionette.start_session() + + alert = self.marionette.switch_to_alert() + alert.dismiss() + + @parameterized("content", 1) + @parameterized("tab", 2) + def test_dont_detect_content_and_tab_modal_type_in_another_tab_for_type(self, type): + self.open_custom_prompt(type, delay=0.25) + + self.marionette.switch_to_window(self.start_tab) + with self.assertRaises(errors.TimeoutException): + self.wait_for_alert(2) + + self.marionette.switch_to_window(self.new_tab) + alert = self.marionette.switch_to_alert() + alert.dismiss() + + @parameterized("window", 3) + @parameterized("internal_window", 4) + def test_detect_window_modal_type_in_another_tab_for_type(self, type): + self.open_custom_prompt(type, delay=0.25) + + self.marionette.switch_to_window(self.start_tab) + self.wait_for_alert() + + alert = self.marionette.switch_to_alert() + alert.dismiss() + + self.marionette.switch_to_window(self.new_tab) + self.assertFalse(self.alert_present) + + @parameterized("window", 3) + @parameterized("internal_window", 4) + def test_detect_window_modal_type_in_another_window_for_type(self, type): + self.new_window = self.open_window() + + self.marionette.switch_to_window(self.new_window) + + self.open_custom_prompt(type, delay=0.25) + + self.marionette.switch_to_window(self.new_tab) + with self.assertRaises(errors.TimeoutException): + self.wait_for_alert(2) + + self.marionette.switch_to_window(self.new_window) + alert = self.marionette.switch_to_alert() + alert.dismiss() + + self.marionette.switch_to_window(self.new_tab) + self.assertFalse(self.alert_present) + + def test_http_auth_dismiss(self): + with self.marionette.using_prefs({self.http_auth_pref: True}): + self.marionette.navigate(self.marionette.absolute_url("http_auth")) + self.wait_for_alert(timeout=self.marionette.timeout.page_load) + + alert = self.marionette.switch_to_alert() + alert.dismiss() + + status = Wait( + self.marionette, timeout=self.marionette.timeout.page_load + ).until(element_present(By.ID, "status")) + self.assertEqual(status.text, "restricted") + + def test_http_auth_send_keys(self): + with self.marionette.using_prefs({self.http_auth_pref: True}): + self.marionette.navigate(self.marionette.absolute_url("http_auth")) + self.wait_for_alert(timeout=self.marionette.timeout.page_load) + + alert = self.marionette.switch_to_alert() + with self.assertRaises(errors.UnsupportedOperationException): + alert.send_keys("foo") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_navigation.py b/testing/marionette/harness/marionette_harness/tests/unit/test_navigation.py new file mode 100644 index 0000000000..ec1a8f1be0 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_navigation.py @@ -0,0 +1,901 @@ +# 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 contextlib +import os + +from six.moves.urllib.parse import quote + +from marionette_driver import By, errors, expected, Wait +from marionette_driver.keys import Keys +from marionette_driver.marionette import Alert +from marionette_harness import ( + MarionetteTestCase, + run_if_manage_instance, + WindowManagerMixin, +) + +here = os.path.abspath(os.path.dirname(__file__)) + + +BLACK_PIXEL = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" # noqa +RED_PIXEL = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/TQBcNTh/AAAAAXRSTlPM0jRW/QAAAApJREFUeJxjYgAAAAYAAzY3fKgAAAAASUVORK5CYII=" # noqa + + +def inline(doc): + return "data:text/html;charset=utf-8,%s" % quote(doc) + + +def inline_image(data): + return "data:image/png;base64,%s" % data + + +class BaseNavigationTestCase(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(BaseNavigationTestCase, self).setUp() + + file_path = os.path.join(here, "data", "test.html").replace("\\", "/") + + self.test_page_file_url = "file:///{}".format(file_path) + self.test_page_frameset = self.marionette.absolute_url("frameset.html") + self.test_page_insecure = self.fixtures.where_is("test.html", on="https") + self.test_page_not_remote = "about:robots" + self.test_page_push_state = self.marionette.absolute_url( + "navigation_pushstate.html" + ) + self.test_page_remote = self.marionette.absolute_url("test.html") + + if self.marionette.session_capabilities["platformName"] == "mac": + self.mod_key = Keys.META + else: + self.mod_key = Keys.CONTROL + + # Always use a blank new tab for an empty history + self.new_tab = self.open_tab() + self.marionette.switch_to_window(self.new_tab) + Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + lambda _: self.history_length == 1, + message="The newly opened tab doesn't have a browser history length of 1", + ) + + def tearDown(self): + self.marionette.timeout.reset() + + self.close_all_tabs() + + super(BaseNavigationTestCase, self).tearDown() + + @property + def history_length(self): + return self.marionette.execute_script("return window.history.length;") + + @property + def is_remote_tab(self): + with self.marionette.using_context("chrome"): + # TODO: DO NOT USE MOST RECENT WINDOW BUT CURRENT ONE + return self.marionette.execute_script( + """ + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + let win = null; + + if (AppConstants.MOZ_APP_NAME == "fennec") { + win = Services.wm.getMostRecentWindow("navigator:browser"); + } else { + const { BrowserWindowTracker } = ChromeUtils.importESModule( + "resource:///modules/BrowserWindowTracker.sys.mjs" + ); + win = BrowserWindowTracker.getTopWindow(); + } + + let tabBrowser = null; + + // Fennec + if (win.BrowserApp) { + tabBrowser = win.BrowserApp.selectedBrowser; + + // Firefox + } else if (win.gBrowser) { + tabBrowser = win.gBrowser.selectedBrowser; + + } else { + return null; + } + + return tabBrowser.isRemoteBrowser; + """ + ) + + @property + def ready_state(self): + return self.marionette.execute_script( + "return window.document.readyState;", sandbox=None + ) + + +class TestNavigate(BaseNavigationTestCase): + def test_set_location_through_execute_script(self): + # To avoid unexpected remoteness changes and a hang in any non-navigation + # command (bug 1519354) when navigating via the location bar, already + # pre-load a page which causes a remoteness change. + self.marionette.navigate(self.test_page_push_state) + + self.marionette.execute_script( + "window.location.href = arguments[0];", + script_args=(self.test_page_remote,), + sandbox=None, + ) + + Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + expected.element_present(*(By.ID, "testh1")), + message="Target element 'testh1' has not been found", + ) + + self.assertEqual(self.test_page_remote, self.marionette.get_url()) + + def test_navigate_chrome_unsupported_error(self): + with self.marionette.using_context("chrome"): + self.assertRaises( + errors.UnsupportedOperationException, + self.marionette.navigate, + "about:blank", + ) + self.assertRaises( + errors.UnsupportedOperationException, self.marionette.go_back + ) + self.assertRaises( + errors.UnsupportedOperationException, self.marionette.go_forward + ) + self.assertRaises( + errors.UnsupportedOperationException, self.marionette.refresh + ) + + def test_get_current_url_returns_top_level_browsing_context_url(self): + page_iframe = self.marionette.absolute_url("test_iframe.html") + + self.marionette.navigate(page_iframe) + self.assertEqual(page_iframe, self.marionette.get_url()) + frame = self.marionette.find_element(By.CSS_SELECTOR, "#test_iframe") + self.marionette.switch_to_frame(frame) + self.assertEqual(page_iframe, self.marionette.get_url()) + + def test_get_current_url(self): + self.marionette.navigate(self.test_page_remote) + self.assertEqual(self.test_page_remote, self.marionette.get_url()) + self.marionette.navigate("about:blank") + self.assertEqual("about:blank", self.marionette.get_url()) + + def test_navigate_in_child_frame_changes_to_top(self): + self.marionette.navigate(self.test_page_frameset) + frame = self.marionette.find_element(By.NAME, "third") + self.marionette.switch_to_frame(frame) + self.assertRaises( + errors.NoSuchElementException, + self.marionette.find_element, + By.NAME, + "third", + ) + + self.marionette.navigate(self.test_page_frameset) + self.marionette.find_element(By.NAME, "third") + + def test_invalid_url(self): + with self.assertRaises(errors.MarionetteException): + self.marionette.navigate("foo") + with self.assertRaises(errors.MarionetteException): + self.marionette.navigate("thisprotocoldoesnotexist://") + + def test_find_element_state_complete(self): + self.marionette.navigate(self.test_page_remote) + self.assertEqual("complete", self.ready_state) + self.assertTrue(self.marionette.find_element(By.ID, "mozLink")) + + def test_navigate_timeout_error_no_remoteness_change(self): + is_remote_before_timeout = self.is_remote_tab + self.marionette.timeout.page_load = 0.5 + with self.assertRaises(errors.TimeoutException): + self.marionette.navigate(self.marionette.absolute_url("slow")) + self.assertEqual(self.is_remote_tab, is_remote_before_timeout) + + def test_navigate_timeout_error_remoteness_change(self): + self.assertTrue(self.is_remote_tab) + self.marionette.navigate("about:robots") + self.assertFalse(self.is_remote_tab) + + self.marionette.timeout.page_load = 0.5 + with self.assertRaises(errors.TimeoutException): + self.marionette.navigate(self.marionette.absolute_url("slow")) + + def test_navigate_to_same_image_document_twice(self): + self.marionette.navigate(self.fixtures.where_is("black.png")) + self.assertIn("black.png", self.marionette.title) + self.marionette.navigate(self.fixtures.where_is("black.png")) + self.assertIn("black.png", self.marionette.title) + + def test_navigate_hash_change(self): + doc = inline("<p id=foo>") + self.marionette.navigate(doc) + self.marionette.execute_script("window.visited = true", sandbox=None) + self.marionette.navigate("{}#foo".format(doc)) + self.assertTrue( + self.marionette.execute_script("return window.visited", sandbox=None) + ) + + def test_navigate_hash_argument_identical(self): + test_page = "{}#foo".format(inline("<p id=foo>")) + + self.marionette.navigate(test_page) + self.marionette.find_element(By.ID, "foo") + self.marionette.navigate(test_page) + self.marionette.find_element(By.ID, "foo") + + def test_navigate_hash_argument_differnt(self): + test_page = "{}#Foo".format(inline("<p id=foo>")) + + self.marionette.navigate(test_page) + self.marionette.find_element(By.ID, "foo") + self.marionette.navigate(test_page.lower()) + self.marionette.find_element(By.ID, "foo") + + def test_navigate_history_pushstate(self): + target_page = self.marionette.absolute_url("navigation_pushstate_target.html") + + self.marionette.navigate(self.test_page_push_state) + self.marionette.find_element(By.ID, "forward").click() + + # By using pushState() the URL is updated but the target page is not loaded + # and as such the element is not displayed + self.assertEqual(self.marionette.get_url(), target_page) + with self.assertRaises(errors.NoSuchElementException): + self.marionette.find_element(By.ID, "target") + + self.marionette.go_back() + self.assertEqual(self.marionette.get_url(), self.test_page_push_state) + + # The target page still gets not loaded + self.marionette.go_forward() + self.assertEqual(self.marionette.get_url(), target_page) + with self.assertRaises(errors.NoSuchElementException): + self.marionette.find_element(By.ID, "target") + + # Navigating to a different page, and returning to the injected + # page, it will be loaded. + self.marionette.navigate(self.test_page_remote) + self.assertEqual(self.marionette.get_url(), self.test_page_remote) + + self.marionette.go_back() + self.assertEqual(self.marionette.get_url(), target_page) + self.marionette.find_element(By.ID, "target") + + self.marionette.go_back() + self.assertEqual(self.marionette.get_url(), self.test_page_push_state) + + def test_navigate_file_url(self): + self.marionette.navigate(self.test_page_file_url) + self.marionette.find_element(By.ID, "file-url") + self.marionette.navigate(self.test_page_remote) + + def test_navigate_file_url_remoteness_change(self): + self.marionette.navigate("about:robots") + self.assertFalse(self.is_remote_tab) + + self.marionette.navigate(self.test_page_file_url) + self.assertTrue(self.is_remote_tab) + self.marionette.find_element(By.ID, "file-url") + + self.marionette.navigate("about:robots") + self.assertFalse(self.is_remote_tab) + + def test_no_such_element_after_remoteness_change(self): + self.marionette.navigate(self.test_page_file_url) + self.assertTrue(self.is_remote_tab) + elem = self.marionette.find_element(By.ID, "file-url") + + self.marionette.navigate("about:robots") + self.assertFalse(self.is_remote_tab) + + with self.assertRaises(errors.StaleElementException): + elem.click() + + def test_about_blank_for_new_docshell(self): + self.assertEqual(self.marionette.get_url(), "about:blank") + + self.marionette.navigate("about:blank") + + def test_about_newtab(self): + with self.marionette.using_prefs({"browser.newtabpage.enabled": True}): + self.marionette.navigate("about:newtab") + + self.marionette.navigate(self.test_page_remote) + self.marionette.find_element(By.ID, "testDiv") + + @run_if_manage_instance("Only runnable if Marionette manages the instance") + def test_focus_after_navigation(self): + self.marionette.restart() + + self.marionette.navigate(inline("<input autofocus>")) + + # Per spec, autofocus candidates will be + # flushed by next paint, so we use rAF here to + # ensure the candidates are flushed. + self.marionette.execute_async_script( + """ + const callback = arguments[arguments.length - 1]; + window.requestAnimationFrame(function() { + window.requestAnimationFrame(callback); + }); + """ + ) + focus_el = self.marionette.find_element(By.CSS_SELECTOR, ":focus") + self.assertEqual(self.marionette.get_active_element(), focus_el) + + def test_no_hang_when_navigating_after_closing_original_tab(self): + # Close the start tab + self.marionette.switch_to_window(self.start_tab) + self.marionette.close() + + self.marionette.switch_to_window(self.new_tab) + self.marionette.navigate(self.test_page_remote) + + def test_type_to_non_remote_tab(self): + self.marionette.navigate(self.test_page_not_remote) + self.assertFalse(self.is_remote_tab) + + with self.marionette.using_context("chrome"): + urlbar = self.marionette.find_element(By.ID, "urlbar-input") + urlbar.send_keys(self.mod_key + "a") + urlbar.send_keys(self.mod_key + "x") + urlbar.send_keys("about:support" + Keys.ENTER) + + Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + lambda mn: mn.get_url() == "about:support", + message="'about:support' hasn't been loaded", + ) + self.assertFalse(self.is_remote_tab) + + def test_type_to_remote_tab(self): + self.assertTrue(self.is_remote_tab) + + with self.marionette.using_context("chrome"): + urlbar = self.marionette.find_element(By.ID, "urlbar-input") + urlbar.send_keys(self.mod_key + "a") + urlbar.send_keys(self.mod_key + "x") + urlbar.send_keys(self.test_page_remote + Keys.ENTER) + + Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + lambda mn: mn.get_url() == self.test_page_remote, + message="'{}' hasn't been loaded".format(self.test_page_remote), + ) + self.assertTrue(self.is_remote_tab) + + def test_navigate_after_deleting_session(self): + self.marionette.delete_session() + self.marionette.start_session() + + self.marionette.navigate(self.test_page_remote) + self.assertEqual(self.test_page_remote, self.marionette.get_url()) + + +class TestBackForwardNavigation(BaseNavigationTestCase): + def run_bfcache_test(self, test_pages): + # Helper method to run simple back and forward testcases. + + def check_page_status(page, expected_history_length): + if "alert_text" in page: + self.assertEqual(Alert(self.marionette).text, page["alert_text"]) + + self.assertEqual(self.marionette.get_url(), page["url"]) + self.assertEqual(self.history_length, expected_history_length) + + if "is_remote" in page: + self.assertEqual( + page["is_remote"], + self.is_remote_tab, + "'{}' doesn't match expected remoteness state: {}".format( + page["url"], page["is_remote"] + ), + ) + + if "callback" in page and callable(page["callback"]): + page["callback"]() + + for index, page in enumerate(test_pages): + if "error" in page: + with self.assertRaises(page["error"]): + self.marionette.navigate(page["url"]) + else: + self.marionette.navigate(page["url"]) + + check_page_status(page, index + 1) + + # Now going back in history for all test pages by backward iterating + # through the list (-1) and skipping the first entry at the end (-2). + for page in test_pages[-2::-1]: + if "error" in page: + with self.assertRaises(page["error"]): + self.marionette.go_back() + else: + self.marionette.go_back() + + check_page_status(page, len(test_pages)) + + # Now going forward in history by skipping the first entry. + for page in test_pages[1::]: + if "error" in page: + with self.assertRaises(page["error"]): + self.marionette.go_forward() + else: + self.marionette.go_forward() + + check_page_status(page, len(test_pages)) + + def test_no_history_items(self): + # Both methods should not raise a failure if no navigation is possible + self.marionette.go_back() + self.marionette.go_forward() + + def test_data_urls(self): + test_pages = [ + {"url": inline("<p>foobar</p>")}, + {"url": self.test_page_remote}, + {"url": inline("<p>foobar</p>")}, + ] + self.run_bfcache_test(test_pages) + + def test_same_document_hash_change(self): + test_pages = [ + {"url": "{}#23".format(self.test_page_remote)}, + {"url": self.test_page_remote}, + {"url": "{}#42".format(self.test_page_remote)}, + ] + self.run_bfcache_test(test_pages) + + def test_file_url(self): + test_pages = [ + {"url": self.test_page_remote}, + {"url": self.test_page_file_url}, + {"url": self.test_page_remote}, + ] + self.run_bfcache_test(test_pages) + + def test_frameset(self): + test_pages = [ + {"url": self.marionette.absolute_url("frameset.html")}, + {"url": self.test_page_remote}, + {"url": self.marionette.absolute_url("frameset.html")}, + ] + self.run_bfcache_test(test_pages) + + def test_frameset_after_navigating_in_frame(self): + test_element_locator = (By.ID, "email") + + self.marionette.navigate(self.test_page_remote) + self.assertEqual(self.marionette.get_url(), self.test_page_remote) + self.assertEqual(self.history_length, 1) + page = self.marionette.absolute_url("frameset.html") + self.marionette.navigate(page) + self.assertEqual(self.marionette.get_url(), page) + self.assertEqual(self.history_length, 2) + frame = self.marionette.find_element(By.ID, "fifth") + self.marionette.switch_to_frame(frame) + link = self.marionette.find_element(By.ID, "linkId") + link.click() + + # We cannot use get_url() to wait until the target page has been loaded, + # because it will return the URL of the top browsing context and doesn't + # wait for the page load to be complete. + Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + expected.element_present(*test_element_locator), + message="Target element 'email' has not been found", + ) + self.assertEqual(self.history_length, 3) + + # Go back to the frame the click navigated away from + self.marionette.go_back() + self.assertEqual(self.marionette.get_url(), page) + with self.assertRaises(errors.NoSuchElementException): + self.marionette.find_element(*test_element_locator) + + # Go back to the non-frameset page + self.marionette.switch_to_parent_frame() + self.marionette.go_back() + self.assertEqual(self.marionette.get_url(), self.test_page_remote) + + # Go forward to the frameset page + self.marionette.go_forward() + self.assertEqual(self.marionette.get_url(), page) + + # Go forward to the frame the click navigated to + # TODO: See above for automatic browser context switches. Hard to do here + frame = self.marionette.find_element(By.ID, "fifth") + self.marionette.switch_to_frame(frame) + self.marionette.go_forward() + self.marionette.find_element(*test_element_locator) + self.assertEqual(self.marionette.get_url(), page) + + def test_image_to_html_to_image(self): + test_pages = [ + {"url": self.marionette.absolute_url("black.png")}, + {"url": self.test_page_remote}, + {"url": self.marionette.absolute_url("white.png")}, + ] + self.run_bfcache_test(test_pages) + + def test_image_to_image(self): + test_pages = [ + {"url": self.marionette.absolute_url("black.png")}, + {"url": self.marionette.absolute_url("white.png")}, + {"url": inline_image(RED_PIXEL)}, + {"url": inline_image(BLACK_PIXEL)}, + {"url": self.marionette.absolute_url("black.png")}, + ] + self.run_bfcache_test(test_pages) + + def test_remoteness_change(self): + test_pages = [ + {"url": "about:robots", "is_remote": False}, + {"url": self.test_page_remote, "is_remote": True}, + {"url": "about:robots", "is_remote": False}, + ] + self.run_bfcache_test(test_pages) + + def test_non_remote_about_pages(self): + test_pages = [ + {"url": "about:preferences", "is_remote": False}, + {"url": "about:robots", "is_remote": False}, + {"url": "about:support", "is_remote": False}, + ] + self.run_bfcache_test(test_pages) + + def test_navigate_to_requested_about_page_after_error_page(self): + test_pages = [ + {"url": "about:neterror"}, + {"url": self.test_page_remote}, + {"url": "about:blocked"}, + ] + self.run_bfcache_test(test_pages) + + def test_timeout_error(self): + urls = [ + self.marionette.absolute_url("slow?delay=3"), + self.test_page_remote, + self.marionette.absolute_url("slow?delay=4"), + ] + + # First, load all pages completely to get them added to the cache + for index, url in enumerate(urls): + self.marionette.navigate(url) + self.assertEqual(url, self.marionette.get_url()) + self.assertEqual(self.history_length, index + 1) + + self.marionette.go_back() + self.assertEqual(urls[1], self.marionette.get_url()) + + # Force triggering a timeout error + self.marionette.timeout.page_load = 0.5 + with self.assertRaises(errors.TimeoutException): + self.marionette.go_back() + self.marionette.timeout.reset() + + delay = Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + expected.element_present(By.ID, "delay"), + message="Target element 'delay' has not been found after timeout in 'back'", + ) + self.assertEqual(delay.text, "3") + + self.marionette.go_forward() + self.assertEqual(urls[1], self.marionette.get_url()) + + # Force triggering a timeout error + self.marionette.timeout.page_load = 0.5 + with self.assertRaises(errors.TimeoutException): + self.marionette.go_forward() + self.marionette.timeout.reset() + + delay = Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + expected.element_present(By.ID, "delay"), + message="Target element 'delay' has not been found after timeout in 'forward'", + ) + self.assertEqual(delay.text, "4") + + def test_certificate_error(self): + test_pages = [ + { + "url": self.test_page_insecure, + "error": errors.InsecureCertificateException, + }, + {"url": self.test_page_remote}, + { + "url": self.test_page_insecure, + "error": errors.InsecureCertificateException, + }, + ] + self.run_bfcache_test(test_pages) + + +class TestRefresh(BaseNavigationTestCase): + def test_basic(self): + self.marionette.navigate(self.test_page_remote) + self.assertEqual(self.test_page_remote, self.marionette.get_url()) + + self.marionette.execute_script( + """ + let elem = window.document.createElement('div'); + elem.id = 'someDiv'; + window.document.body.appendChild(elem); + """ + ) + self.marionette.find_element(By.ID, "someDiv") + + self.marionette.refresh() + self.assertEqual(self.test_page_remote, self.marionette.get_url()) + with self.assertRaises(errors.NoSuchElementException): + self.marionette.find_element(By.ID, "someDiv") + + def test_refresh_in_child_frame_navigates_to_top(self): + self.marionette.navigate(self.test_page_frameset) + self.assertEqual(self.test_page_frameset, self.marionette.get_url()) + + frame = self.marionette.find_element(By.NAME, "third") + self.marionette.switch_to_frame(frame) + self.assertRaises( + errors.NoSuchElementException, + self.marionette.find_element, + By.NAME, + "third", + ) + + self.marionette.refresh() + self.marionette.find_element(By.NAME, "third") + + def test_file_url(self): + self.marionette.navigate(self.test_page_file_url) + self.assertEqual(self.test_page_file_url, self.marionette.get_url()) + + self.marionette.refresh() + self.assertEqual(self.test_page_file_url, self.marionette.get_url()) + + def test_image(self): + image = self.marionette.absolute_url("black.png") + + self.marionette.navigate(image) + self.assertEqual(image, self.marionette.get_url()) + + self.marionette.refresh() + self.assertEqual(image, self.marionette.get_url()) + + def test_history_pushstate(self): + target_page = self.marionette.absolute_url("navigation_pushstate_target.html") + + self.marionette.navigate(self.test_page_push_state) + self.marionette.find_element(By.ID, "forward").click() + + # By using pushState() the URL is updated but the target page is not loaded + # and as such the element is not displayed + self.assertEqual(self.marionette.get_url(), target_page) + with self.assertRaises(errors.NoSuchElementException): + self.marionette.find_element(By.ID, "target") + + # Refreshing the target page will trigger a full page load. + self.marionette.refresh() + self.assertEqual(self.marionette.get_url(), target_page) + self.marionette.find_element(By.ID, "target") + + self.marionette.go_back() + self.assertEqual(self.marionette.get_url(), self.test_page_push_state) + + def test_timeout_error(self): + slow_page = self.marionette.absolute_url("slow?delay=3") + + self.marionette.navigate(slow_page) + self.assertEqual(slow_page, self.marionette.get_url()) + + self.marionette.timeout.page_load = 0.5 + with self.assertRaises(errors.TimeoutException): + self.marionette.refresh() + self.assertEqual(slow_page, self.marionette.get_url()) + + def test_insecure_error(self): + with self.assertRaises(errors.InsecureCertificateException): + self.marionette.navigate(self.test_page_insecure) + self.assertEqual(self.marionette.get_url(), self.test_page_insecure) + + with self.assertRaises(errors.InsecureCertificateException): + self.marionette.refresh() + + +class TestTLSNavigation(BaseNavigationTestCase): + insecure_tls = {"acceptInsecureCerts": True} + secure_tls = {"acceptInsecureCerts": False} + + def setUp(self): + super(TestTLSNavigation, self).setUp() + + self.test_page_insecure = self.fixtures.where_is("test.html", on="https") + + self.marionette.delete_session() + self.capabilities = self.marionette.start_session(self.insecure_tls) + + def tearDown(self): + try: + self.marionette.delete_session() + self.marionette.start_session() + except: + pass + + super(TestTLSNavigation, self).tearDown() + + @contextlib.contextmanager + def safe_session(self): + try: + self.capabilities = self.marionette.start_session(self.secure_tls) + self.assertFalse(self.capabilities["acceptInsecureCerts"]) + # Always use a blank new tab for an empty history + self.new_tab = self.open_tab() + self.marionette.switch_to_window(self.new_tab) + Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + lambda _: self.history_length == 1, + message="The newly opened tab doesn't have a browser history length of 1", + ) + yield self.marionette + finally: + self.close_all_tabs() + self.marionette.delete_session() + + @contextlib.contextmanager + def unsafe_session(self): + try: + self.capabilities = self.marionette.start_session(self.insecure_tls) + self.assertTrue(self.capabilities["acceptInsecureCerts"]) + # Always use a blank new tab for an empty history + self.new_tab = self.open_tab() + self.marionette.switch_to_window(self.new_tab) + Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + lambda _: self.history_length == 1, + message="The newly opened tab doesn't have a browser history length of 1", + ) + yield self.marionette + finally: + self.close_all_tabs() + self.marionette.delete_session() + + def test_navigate_by_command(self): + self.marionette.navigate(self.test_page_insecure) + self.assertIn("https", self.marionette.get_url()) + + def test_navigate_by_click(self): + link_url = self.test_page_insecure + self.marionette.navigate( + inline("<a href=%s>https is the future</a>" % link_url) + ) + self.marionette.find_element(By.TAG_NAME, "a").click() + self.assertIn("https", self.marionette.get_url()) + + def test_deactivation(self): + invalid_cert_url = self.test_page_insecure + + self.marionette.delete_session() + + print("with safe session") + with self.safe_session() as session: + with self.assertRaises(errors.InsecureCertificateException): + session.navigate(invalid_cert_url) + + print("with unsafe session") + with self.unsafe_session() as session: + session.navigate(invalid_cert_url) + + print("with safe session again") + with self.safe_session() as session: + with self.assertRaises(errors.InsecureCertificateException): + session.navigate(invalid_cert_url) + + +class TestPageLoadStrategy(BaseNavigationTestCase): + def setUp(self): + super(TestPageLoadStrategy, self).setUp() + + # Test page that delays the response and as such the document to be + # loaded. It is used for testing the page load strategy "none". + self.test_page_slow = self.marionette.absolute_url("slow") + + # Similar to "slow" but additionally triggers a cross group navigation + # which triggers a replacement of the top-level browsing context. + self.test_page_slow_coop = self.marionette.absolute_url("slow-coop") + + # Test page that contains a slow loading <img> element which delays the + # "load" but not the "DOMContentLoaded" event. + self.test_page_slow_resource = self.marionette.absolute_url( + "slow_resource.html" + ) + + def tearDown(self): + self.marionette.delete_session() + self.marionette.start_session() + + super(TestPageLoadStrategy, self).tearDown() + + def test_none(self): + self.marionette.delete_session() + self.marionette.start_session({"pageLoadStrategy": "none"}) + + # Navigate will return immediately. As such wait for the target URL to + # be the current location, and the element to exist. + self.marionette.navigate(self.test_page_slow) + with self.assertRaises(errors.NoSuchElementException): + self.marionette.find_element(By.ID, "delay") + + Wait( + self.marionette, + ignored_exceptions=errors.NoSuchElementException, + timeout=self.marionette.timeout.page_load, + ).until(lambda _: self.marionette.find_element(By.ID, "delay")) + + self.assertEqual(self.marionette.get_url(), self.test_page_slow) + + def test_none_with_new_session_waits_for_page_loaded(self): + self.marionette.delete_session() + self.marionette.start_session({"pageLoadStrategy": "none"}) + + # Navigate will return immediately. + self.marionette.navigate(self.test_page_slow) + + # Make sure that when creating a new session right away it waits + # until the page has been finished loading. + self.marionette.delete_session() + self.marionette.start_session() + + self.assertEqual(self.marionette.get_url(), self.test_page_slow) + self.assertEqual(self.ready_state, "complete") + self.marionette.find_element(By.ID, "delay") + + def test_none_with_new_session_waits_for_page_loaded_remoteness_change(self): + self.marionette.delete_session() + self.marionette.start_session({"pageLoadStrategy": "none"}) + + # Navigate will return immediately. + self.marionette.navigate(self.test_page_slow_coop) + + # Make sure that when creating a new session right away it waits + # until the page has been finished loading. + self.marionette.delete_session() + self.marionette.start_session() + + self.assertEqual(self.marionette.get_url(), self.test_page_slow_coop) + self.assertEqual(self.ready_state, "complete") + self.marionette.find_element(By.ID, "delay") + + def test_eager(self): + self.marionette.delete_session() + self.marionette.start_session({"pageLoadStrategy": "eager"}) + + self.marionette.navigate(self.test_page_slow_resource) + self.assertEqual(self.ready_state, "interactive") + self.assertEqual(self.marionette.get_url(), self.test_page_slow_resource) + self.marionette.find_element(By.ID, "slow") + + def test_normal(self): + self.marionette.delete_session() + self.marionette.start_session({"pageLoadStrategy": "normal"}) + + self.marionette.navigate(self.test_page_slow_resource) + self.assertEqual(self.marionette.get_url(), self.test_page_slow_resource) + self.assertEqual(self.ready_state, "complete") + self.marionette.find_element(By.ID, "slow") + + def test_strategy_after_remoteness_change(self): + """Bug 1378191 - Reset of capabilities after listener reload.""" + self.marionette.delete_session() + self.marionette.start_session({"pageLoadStrategy": "eager"}) + + # Trigger a remoteness change which will reload the listener script + self.assertTrue( + self.is_remote_tab, "Initial tab doesn't have remoteness flag set" + ) + self.marionette.navigate("about:robots") + self.assertFalse(self.is_remote_tab, "Tab has remoteness flag set") + self.marionette.navigate(self.test_page_slow_resource) + self.assertEqual(self.ready_state, "interactive") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_pagesource.py b/testing/marionette/harness/marionette_harness/tests/unit/test_pagesource.py new file mode 100644 index 0000000000..e3799bc0d6 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_pagesource.py @@ -0,0 +1,52 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from six.moves.urllib.parse import quote + +from marionette_harness import MarionetteTestCase + + +def inline(doc, mime=None, charset=None): + mime = "html" if mime is None else mime + charset = "utf-8" if (charset is None) else charset + return "data:text/{};charset={},{}".format(mime, charset, quote(doc)) + + +class TestPageSource(MarionetteTestCase): + def testShouldReturnTheSourceOfAPage(self): + test_html = inline("<body><p> Check the PageSource</body>") + self.marionette.navigate(test_html) + source = self.marionette.page_source + from_web_api = self.marionette.execute_script( + "return document.documentElement.outerHTML" + ) + self.assertTrue("<html" in source) + self.assertTrue("PageSource" in source) + self.assertEqual(source, from_web_api) + + def testShouldReturnTheSourceOfAPageWhenThereAreUnicodeChars(self): + test_html = inline( + '<head><meta http-equiv="pragma" content="no-cache"/></head><body><!-- the \u00ab section[id^="wifi-"] \u00bb selector.--></body>' + ) + self.marionette.navigate(test_html) + # if we don't throw on the next line we are good! + source = self.marionette.page_source + from_web_api = self.marionette.execute_script( + "return document.documentElement.outerHTML" + ) + self.assertEqual(source, from_web_api) + + def testShouldReturnAXMLDocumentSource(self): + test_xml = inline("<xml><foo><bar>baz</bar></foo></xml>", "xml") + self.marionette.navigate(test_xml) + source = self.marionette.page_source + from_web_api = self.marionette.execute_script( + "return document.documentElement.outerHTML" + ) + import re + + self.assertEqual( + re.sub("\s", "", source), "<xml><foo><bar>baz</bar></foo></xml>" + ) + self.assertEqual(source, from_web_api) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_pagesource_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_pagesource_chrome.py new file mode 100644 index 0000000000..029be1471f --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_pagesource_chrome.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/. + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestPageSourceChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestPageSourceChrome, self).setUp() + self.marionette.set_context("chrome") + + new_window = self.open_chrome_window( + "chrome://remote/content/marionette/test.xhtml" + ) + self.marionette.switch_to_window(new_window) + + def tearDown(self): + self.close_all_windows() + super(TestPageSourceChrome, self).tearDown() + + def testShouldReturnXULDetails(self): + source = self.marionette.page_source + self.assertTrue( + '<input xmlns="http://www.w3.org/1999/xhtml" id="textInput"' in source + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_position.py b/testing/marionette/harness/marionette_harness/tests/unit/test_position.py new file mode 100644 index 0000000000..f2a409c1a2 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_position.py @@ -0,0 +1,46 @@ +# 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 six.moves.urllib.parse import quote + +from marionette_driver.by import By + +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestPosition(MarionetteTestCase): + def test_should_get_element_position_back(self): + doc = """ + <head> + <title>Rectangles</title> + <style> + div { + position: absolute; + margin: 0; + border: 0; + padding: 0; + } + #r { + background-color: red; + left: 11px; + top: 10px; + width: 48.666666667px; + height: 49.333333333px; + } + </style> + </head> + <body> + <div id="r">r</div> + </body> + """ + self.marionette.navigate(inline(doc)) + + r2 = self.marionette.find_element(By.ID, "r") + location = r2.rect + self.assertEqual(11, location["x"]) + self.assertEqual(10, location["y"]) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_prefs.py b/testing/marionette/harness/marionette_harness/tests/unit/test_prefs.py new file mode 100644 index 0000000000..4e4de0da54 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_prefs.py @@ -0,0 +1,213 @@ +# 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 six + +from marionette_driver import geckoinstance +from marionette_driver.errors import JavascriptException + +from marionette_harness import ( + MarionetteTestCase, + run_if_manage_instance, +) + + +class TestPreferences(MarionetteTestCase): + prefs = { + "bool": "marionette.test.bool", + "int": "marionette.test.int", + "string": "marionette.test.string", + } + + def tearDown(self): + for pref in self.prefs.values(): + self.marionette.clear_pref(pref) + + super(TestPreferences, self).tearDown() + + def test_gecko_instance_preferences(self): + required_prefs = geckoinstance.GeckoInstance.required_prefs + + for key, value in six.iteritems(required_prefs): + self.assertEqual( + self.marionette.get_pref(key), + value, + "Preference {} hasn't been set to {}".format(key, repr(value)), + ) + + def test_desktop_instance_preferences(self): + required_prefs = geckoinstance.DesktopInstance.desktop_prefs + + for key, value in six.iteritems(required_prefs): + self.assertEqual( + self.marionette.get_pref(key), + value, + "Preference {} hasn't been set to {}".format(key, value), + ) + + def test_clear_pref(self): + self.assertIsNone(self.marionette.get_pref(self.prefs["bool"])) + + self.marionette.set_pref(self.prefs["bool"], True) + self.assertTrue(self.marionette.get_pref(self.prefs["bool"])) + + self.marionette.clear_pref(self.prefs["bool"]) + self.assertIsNone(self.marionette.get_pref(self.prefs["bool"])) + + def test_get_and_set_pref(self): + # By default none of the preferences are set + self.assertIsNone(self.marionette.get_pref(self.prefs["bool"])) + self.assertIsNone(self.marionette.get_pref(self.prefs["int"])) + self.assertIsNone(self.marionette.get_pref(self.prefs["string"])) + + # Test boolean values + self.marionette.set_pref(self.prefs["bool"], True) + value = self.marionette.get_pref(self.prefs["bool"]) + self.assertTrue(value) + self.assertEqual(type(value), bool) + + # Test int values + self.marionette.set_pref(self.prefs["int"], 42) + value = self.marionette.get_pref(self.prefs["int"]) + self.assertEqual(value, 42) + self.assertEqual(type(value), int) + + # Test string values + self.marionette.set_pref(self.prefs["string"], "abc") + value = self.marionette.get_pref(self.prefs["string"]) + self.assertEqual(value, "abc") + self.assertTrue(isinstance(value, six.string_types)) + + # Test reset value + self.marionette.set_pref(self.prefs["string"], None) + self.assertIsNone(self.marionette.get_pref(self.prefs["string"])) + + def test_get_set_pref_default_branch(self): + pref_default = "marionette.test.pref_default1" + self.assertIsNone(self.marionette.get_pref(self.prefs["string"])) + + self.marionette.set_pref(pref_default, "default_value", default_branch=True) + self.assertEqual(self.marionette.get_pref(pref_default), "default_value") + self.assertEqual( + self.marionette.get_pref(pref_default, default_branch=True), "default_value" + ) + + self.marionette.set_pref(pref_default, "user_value") + self.assertEqual(self.marionette.get_pref(pref_default), "user_value") + self.assertEqual( + self.marionette.get_pref(pref_default, default_branch=True), "default_value" + ) + + self.marionette.clear_pref(pref_default) + self.assertEqual(self.marionette.get_pref(pref_default), "default_value") + + def test_get_pref_value_type(self): + # Without a given value type the properties URL will be returned only + pref_complex = "browser.menu.showCharacterEncoding" + properties_file = "chrome://browser/locale/browser.properties" + self.assertEqual( + self.marionette.get_pref(pref_complex, default_branch=True), properties_file + ) + + # Otherwise the property named like the pref will be translated + value = self.marionette.get_pref( + pref_complex, default_branch=True, value_type="nsIPrefLocalizedString" + ) + self.assertNotEqual(value, properties_file) + + def test_set_prefs(self): + # By default none of the preferences are set + self.assertIsNone(self.marionette.get_pref(self.prefs["bool"])) + self.assertIsNone(self.marionette.get_pref(self.prefs["int"])) + self.assertIsNone(self.marionette.get_pref(self.prefs["string"])) + + # Set a value on the default branch first + pref_default = "marionette.test.pref_default2" + self.assertIsNone(self.marionette.get_pref(pref_default)) + self.marionette.set_prefs({pref_default: "default_value"}, default_branch=True) + + # Set user values + prefs = { + self.prefs["bool"]: True, + self.prefs["int"]: 42, + self.prefs["string"]: "abc", + pref_default: "user_value", + } + self.marionette.set_prefs(prefs) + + self.assertTrue(self.marionette.get_pref(self.prefs["bool"])) + self.assertEqual(self.marionette.get_pref(self.prefs["int"]), 42) + self.assertEqual(self.marionette.get_pref(self.prefs["string"]), "abc") + self.assertEqual(self.marionette.get_pref(pref_default), "user_value") + self.assertEqual( + self.marionette.get_pref(pref_default, default_branch=True), "default_value" + ) + + def test_using_prefs(self): + # Test that multiple preferences can be set with "using_prefs", and that + # they are set correctly and unset correctly after leaving the context + # manager. + pref_not_existent = "marionette.test.not_existent1" + pref_default = "marionette.test.pref_default3" + + self.marionette.set_prefs( + { + self.prefs["string"]: "abc", + self.prefs["int"]: 42, + self.prefs["bool"]: False, + } + ) + self.assertFalse(self.marionette.get_pref(self.prefs["bool"])) + self.assertEqual(self.marionette.get_pref(self.prefs["int"]), 42) + self.assertEqual(self.marionette.get_pref(self.prefs["string"]), "abc") + self.assertIsNone(self.marionette.get_pref(pref_not_existent)) + + with self.marionette.using_prefs( + { + self.prefs["bool"]: True, + self.prefs["int"]: 24, + self.prefs["string"]: "def", + pref_not_existent: "existent", + } + ): + self.assertTrue(self.marionette.get_pref(self.prefs["bool"]), True) + self.assertEqual(self.marionette.get_pref(self.prefs["int"]), 24) + self.assertEqual(self.marionette.get_pref(self.prefs["string"]), "def") + self.assertEqual(self.marionette.get_pref(pref_not_existent), "existent") + + self.assertFalse(self.marionette.get_pref(self.prefs["bool"])) + self.assertEqual(self.marionette.get_pref(self.prefs["int"]), 42) + self.assertEqual(self.marionette.get_pref(self.prefs["string"]), "abc") + self.assertIsNone(self.marionette.get_pref(pref_not_existent)) + + # Using context with default branch + self.marionette.set_pref(pref_default, "default_value", default_branch=True) + self.assertEqual( + self.marionette.get_pref(pref_default, default_branch=True), "default_value" + ) + + with self.marionette.using_prefs( + {pref_default: "new_value"}, default_branch=True + ): + self.assertEqual( + self.marionette.get_pref(pref_default, default_branch=True), "new_value" + ) + + self.assertEqual( + self.marionette.get_pref(pref_default, default_branch=True), "default_value" + ) + + def test_using_prefs_exception(self): + # Test that throwing an exception inside the context manager doesn"t + # prevent the preferences from being restored at context manager exit. + self.marionette.set_pref(self.prefs["string"], "abc") + + try: + with self.marionette.using_prefs({self.prefs["string"]: "def"}): + self.assertEqual(self.marionette.get_pref(self.prefs["string"]), "def") + self.marionette.execute_script("return foo.bar.baz;") + except JavascriptException: + pass + + self.assertEqual(self.marionette.get_pref(self.prefs["string"]), "abc") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_prefs_enforce.py b/testing/marionette/harness/marionette_harness/tests/unit/test_prefs_enforce.py new file mode 100644 index 0000000000..609bed0527 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_prefs_enforce.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/. + +import six + +from marionette_harness import MarionetteTestCase + + +class TestEnforcePreferences(MarionetteTestCase): + def setUp(self): + super(TestEnforcePreferences, self).setUp() + self.marionette.set_context("chrome") + + def tearDown(self): + self.marionette.restart(in_app=False, clean=True) + + super(TestEnforcePreferences, self).tearDown() + + def enforce_prefs(self, prefs=None): + test_prefs = { + "marionette.test.bool": True, + "marionette.test.int": 3, + "marionette.test.string": "testing", + } + + self.marionette.enforce_gecko_prefs(prefs or test_prefs) + + def test_preferences_are_set(self): + self.enforce_prefs() + self.assertTrue(self.marionette.get_pref("marionette.test.bool")) + self.assertEqual(self.marionette.get_pref("marionette.test.string"), "testing") + self.assertEqual(self.marionette.get_pref("marionette.test.int"), 3) + + def test_change_enforced_preference(self): + self.enforce_prefs() + self.assertTrue(self.marionette.get_pref("marionette.test.bool")) + + self.enforce_prefs({"marionette.test.bool": False}) + self.assertFalse(self.marionette.get_pref("marionette.test.bool")) + + def test_restart_with_clean_profile_after_enforce_prefs(self): + self.enforce_prefs() + self.assertTrue(self.marionette.get_pref("marionette.test.bool")) + + self.marionette.restart(in_app=False, clean=True) + self.assertEqual(self.marionette.get_pref("marionette.test.bool"), None) + + def test_restart_preserves_requested_capabilities(self): + self.marionette.delete_session() + self.marionette.start_session(capabilities={"test:fooBar": True}) + + self.enforce_prefs() + self.assertEqual(self.marionette.session.get("test:fooBar"), True) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_profile_management.py b/testing/marionette/harness/marionette_harness/tests/unit/test_profile_management.py new file mode 100644 index 0000000000..1420b88157 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_profile_management.py @@ -0,0 +1,267 @@ +# 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/. + +import os +import shutil +import tempfile + +import mozprofile + +from marionette_driver import errors +from marionette_harness import MarionetteTestCase, parameterized + + +class BaseProfileManagement(MarionetteTestCase): + def setUp(self): + super(BaseProfileManagement, self).setUp() + + self.orig_profile_path = self.profile_path + + def tearDown(self): + shutil.rmtree(self.orig_profile_path, ignore_errors=True) + self.marionette.profile = None + + self.marionette.quit(in_app=False, clean=True) + + super(BaseProfileManagement, self).tearDown() + + @property + def profile(self): + return self.marionette.instance.profile + + @property + def profile_path(self): + return self.marionette.instance.profile.profile + + +class WorkspaceProfileManagement(BaseProfileManagement): + def setUp(self): + super(WorkspaceProfileManagement, self).setUp() + + # Set a new workspace for the instance, which will be used + # the next time a new profile is requested by a test. + self.workspace = tempfile.mkdtemp() + self.marionette.instance.workspace = self.workspace + + def tearDown(self): + self.marionette.instance.workspace = None + + shutil.rmtree(self.workspace, ignore_errors=True) + + super(WorkspaceProfileManagement, self).tearDown() + + +class ExternalProfileMixin(object): + def setUp(self): + super(ExternalProfileMixin, self).setUp() + + # Create external profile + tmp_dir = tempfile.mkdtemp(suffix="external") + shutil.rmtree(tmp_dir, ignore_errors=True) + + # Re-use all the required profile arguments (preferences) + profile_args = self.marionette.instance.profile_args + profile_args["profile"] = tmp_dir + self.external_profile = mozprofile.Profile(**profile_args) + + # Prevent profile from being removed during cleanup + self.external_profile.create_new = False + + def tearDown(self): + shutil.rmtree(self.external_profile.profile, ignore_errors=True) + + super(ExternalProfileMixin, self).tearDown() + + +class TestQuitRestartWithoutWorkspace(BaseProfileManagement): + @parameterized("safe", True) + @parameterized("forced", False) + def test_quit_keeps_same_profile(self, in_app): + self.marionette.quit(in_app=in_app) + self.marionette.start_session() + + self.assertEqual(self.profile_path, self.orig_profile_path) + self.assertTrue(os.path.exists(self.orig_profile_path)) + + def test_quit_clean_creates_new_profile(self): + self.marionette.quit(in_app=False, clean=True) + self.marionette.start_session() + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + @parameterized("safe", True) + @parameterized("forced", False) + def test_restart_keeps_same_profile(self, in_app): + self.marionette.restart(in_app=in_app) + + self.assertEqual(self.profile_path, self.orig_profile_path) + self.assertTrue(os.path.exists(self.orig_profile_path)) + + def test_restart_clean_creates_new_profile(self): + self.marionette.restart(in_app=False, clean=True) + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + +class TestQuitRestartWithWorkspace(WorkspaceProfileManagement): + @parameterized("safe", True) + @parameterized("forced", False) + def test_quit_keeps_same_profile(self, in_app): + self.marionette.quit(in_app=in_app) + self.marionette.start_session() + + self.assertEqual(self.profile_path, self.orig_profile_path) + self.assertNotIn(self.workspace, self.profile_path) + self.assertTrue(os.path.exists(self.orig_profile_path)) + + def test_quit_clean_creates_new_profile(self): + self.marionette.quit(in_app=False, clean=True) + self.marionette.start_session() + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertIn(self.workspace, self.profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + @parameterized("safe", True) + @parameterized("forced", False) + def test_restart_keeps_same_profile(self, in_app): + self.marionette.restart(in_app=in_app) + + self.assertEqual(self.profile_path, self.orig_profile_path) + self.assertNotIn(self.workspace, self.profile_path) + self.assertTrue(os.path.exists(self.orig_profile_path)) + + def test_restart_clean_creates_new_profile(self): + self.marionette.restart(in_app=False, clean=True) + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertIn(self.workspace, self.profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + +class TestSwitchProfileFailures(BaseProfileManagement): + def test_raise_for_switching_profile_while_instance_is_running(self): + with self.assertRaisesRegexp( + errors.MarionetteException, "instance is not running" + ): + self.marionette.instance.switch_profile() + + +class TestSwitchProfileWithoutWorkspace(ExternalProfileMixin, BaseProfileManagement): + def setUp(self): + super(TestSwitchProfileWithoutWorkspace, self).setUp() + + self.marionette.quit() + + def test_do_not_call_cleanup_of_profile_for_path_only(self): + # If a path to a profile has been given (eg. via the --profile command + # line argument) and the profile hasn't been created yet, switching the + # profile should not try to call `cleanup()` on a string. + self.marionette.instance._profile = self.external_profile.profile + self.marionette.instance.switch_profile() + + def test_new_random_profile_name(self): + self.marionette.instance.switch_profile() + self.marionette.start_session() + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + def test_new_named_profile(self): + self.marionette.instance.switch_profile("foobar") + self.marionette.start_session() + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertIn("foobar", self.profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + def test_new_named_profile_unicode(self): + """Test using unicode string with 1-4 bytes encoding works.""" + self.marionette.instance.switch_profile("$¢€🍪") + self.marionette.start_session() + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertIn("$¢€🍪", self.profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + def test_new_named_profile_unicode_escape_characters(self): + """Test using escaped unicode string with 1-4 bytes encoding works.""" + self.marionette.instance.switch_profile("\u0024\u00A2\u20AC\u1F36A") + self.marionette.start_session() + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertIn("\u0024\u00A2\u20AC\u1F36A", self.profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + def test_clone_existing_profile(self): + self.marionette.instance.switch_profile(clone_from=self.external_profile) + self.marionette.start_session() + + self.assertIn( + os.path.basename(self.external_profile.profile), self.profile_path + ) + self.assertTrue(os.path.exists(self.external_profile.profile)) + + def test_replace_with_current_profile(self): + self.marionette.instance.profile = self.profile + self.marionette.start_session() + + self.assertEqual(self.profile_path, self.orig_profile_path) + self.assertTrue(os.path.exists(self.orig_profile_path)) + + def test_replace_with_external_profile(self): + self.marionette.instance.profile = self.external_profile + self.marionette.start_session() + + self.assertEqual(self.profile_path, self.external_profile.profile) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + # Check that required preferences have been correctly set + self.assertFalse(self.marionette.get_pref("remote.prefs.recommended")) + + # Set a new profile and ensure the external profile has not been deleted + self.marionette.quit() + self.marionette.instance.profile = None + + self.assertNotEqual(self.profile_path, self.external_profile.profile) + self.assertTrue(os.path.exists(self.external_profile.profile)) + + +class TestSwitchProfileWithWorkspace(ExternalProfileMixin, WorkspaceProfileManagement): + def setUp(self): + super(TestSwitchProfileWithWorkspace, self).setUp() + + self.marionette.quit() + + def test_new_random_profile_name(self): + self.marionette.instance.switch_profile() + self.marionette.start_session() + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertIn(self.workspace, self.profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + def test_new_named_profile(self): + self.marionette.instance.switch_profile("foobar") + self.marionette.start_session() + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertIn("foobar", self.profile_path) + self.assertIn(self.workspace, self.profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + def test_clone_existing_profile(self): + self.marionette.instance.switch_profile(clone_from=self.external_profile) + self.marionette.start_session() + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertIn(self.workspace, self.profile_path) + self.assertIn( + os.path.basename(self.external_profile.profile), self.profile_path + ) + self.assertTrue(os.path.exists(self.external_profile.profile)) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_proxy.py b/testing/marionette/harness/marionette_harness/tests/unit/test_proxy.py new file mode 100644 index 0000000000..21a37639d9 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_proxy.py @@ -0,0 +1,159 @@ +# 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_driver import errors + +from marionette_harness import MarionetteTestCase + + +class TestProxyCapabilities(MarionetteTestCase): + def setUp(self): + super(TestProxyCapabilities, self).setUp() + + self.marionette.delete_session() + + def tearDown(self): + if not self.marionette.session: + self.marionette.start_session() + + with self.marionette.using_context("chrome"): + self.marionette.execute_script( + """ + const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" + ); + Preferences.resetBranch("network.proxy"); + """ + ) + + super(TestProxyCapabilities, self).tearDown() + + def test_proxy_object_in_returned_capabilities(self): + capabilities = {"proxy": {"proxyType": "system"}} + + self.marionette.start_session(capabilities) + self.assertEqual( + self.marionette.session_capabilities["proxy"], capabilities["proxy"] + ) + + def test_proxy_type_autodetect(self): + capabilities = {"proxy": {"proxyType": "autodetect"}} + + self.marionette.start_session(capabilities) + self.assertEqual( + self.marionette.session_capabilities["proxy"], capabilities["proxy"] + ) + + def test_proxy_type_direct(self): + capabilities = {"proxy": {"proxyType": "direct"}} + + self.marionette.start_session(capabilities) + self.assertEqual( + self.marionette.session_capabilities["proxy"], capabilities["proxy"] + ) + + def test_proxy_type_manual(self): + proxy_hostname = "marionette.test" + capabilities = { + "proxy": { + "proxyType": "manual", + "httpProxy": "{}:80".format(proxy_hostname), + "sslProxy": "{}:443".format(proxy_hostname), + "socksProxy": proxy_hostname, + "socksVersion": 4, + } + } + + self.marionette.start_session(capabilities) + self.assertEqual( + self.marionette.session_capabilities["proxy"], capabilities["proxy"] + ) + + def test_proxy_type_manual_socks_requires_version(self): + proxy_port = 4444 + proxy_hostname = "marionette.test" + proxy_host = "{}:{}".format(proxy_hostname, proxy_port) + capabilities = { + "proxy": { + "proxyType": "manual", + "socksProxy": proxy_host, + } + } + + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session(capabilities) + + def test_proxy_type_manual_no_proxy_on(self): + capabilities = { + "proxy": { + "proxyType": "manual", + "noProxy": ["foo", "bar"], + } + } + + self.marionette.start_session(capabilities) + self.assertEqual( + self.marionette.session_capabilities["proxy"], capabilities["proxy"] + ) + + def test_proxy_type_manual_invalid_no_proxy_on(self): + capabilities = { + "proxy": { + "proxyType": "manual", + "noProxy": "foo, bar", + } + } + + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session(capabilities) + + def test_proxy_type_pac(self): + pac_url = "http://marionette.test" + capabilities = {"proxy": {"proxyType": "pac", "proxyAutoconfigUrl": pac_url}} + + self.marionette.start_session(capabilities) + self.assertEqual( + self.marionette.session_capabilities["proxy"], capabilities["proxy"] + ) + + def test_proxy_type_system(self): + capabilities = {"proxy": {"proxyType": "system"}} + + self.marionette.start_session(capabilities) + self.assertEqual( + self.marionette.session_capabilities["proxy"], capabilities["proxy"] + ) + + def test_invalid_proxy_object(self): + capabilities = {"proxy": "I really should be a dictionary"} + + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session(capabilities) + + def test_missing_proxy_type(self): + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session({"proxy": {"proxyAutoconfigUrl": "foobar"}}) + + def test_invalid_proxy_type(self): + capabilities = {"proxy": {"proxyType": "NOPROXY"}} + + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session(capabilities) + + def test_invalid_autoconfig_url_for_pac(self): + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session({"proxy": {"proxyType": "pac"}}) + + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session( + {"proxy": {"proxyType": "pac", "proxyAutoconfigUrl": None}} + ) + + def test_missing_socks_version_for_manual(self): + capabilities = { + "proxy": {"proxyType": "manual", "socksProxy": "marionette.test"} + } + + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session(capabilities) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_quit_restart.py b/testing/marionette/harness/marionette_harness/tests/unit/test_quit_restart.py new file mode 100644 index 0000000000..f41b374896 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_quit_restart.py @@ -0,0 +1,550 @@ +# 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 sys +import unittest +from urllib.parse import quote + +from marionette_driver import errors +from marionette_driver.by import By +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestServerQuitApplication(MarionetteTestCase): + def tearDown(self): + if self.marionette.session is None: + self.marionette.start_session() + + def quit(self, flags=None, safe_mode=False): + body = {} + if flags is not None: + body["flags"] = list( + flags, + ) + if safe_mode: + body["safeMode"] = safe_mode + + resp = self.marionette._send_message("Marionette:Quit", body) + self.marionette.session_id = None + self.marionette.session = None + self.marionette.process_id = None + self.marionette.profile = None + self.marionette.window = None + + self.assertIn("cause", resp) + + self.marionette.client.close() + self.marionette.instance.runner.wait() + + return resp["cause"] + + def test_types(self): + for typ in [42, True, "foo", []]: + print("testing type {}".format(type(typ))) + with self.assertRaises(errors.InvalidArgumentException): + self.marionette._send_message("Marionette:Quit", typ) + + with self.assertRaises(errors.InvalidArgumentException): + self.quit("foo") + + def test_undefined_default(self): + cause = self.quit() + self.assertEqual("shutdown", cause) + + def test_empty_default(self): + cause = self.quit(()) + self.assertEqual("shutdown", cause) + + def test_incompatible_quit_flags(self): + with self.assertRaises(errors.InvalidArgumentException): + self.quit(("eAttemptQuit", "eForceQuit")) + + def test_attempt_quit(self): + cause = self.quit(("eAttemptQuit",)) + self.assertEqual("shutdown", cause) + + def test_force_quit(self): + cause = self.quit(("eForceQuit",)) + self.assertEqual("shutdown", cause) + + def test_safe_mode_requires_restart(self): + with self.assertRaises(errors.InvalidArgumentException): + self.quit(("eAttemptQuit",), True) + + @unittest.skipUnless(sys.platform.startswith("darwin"), "Only supported on MacOS") + def test_silent_quit_missing_windowless_capability(self): + with self.assertRaises(errors.UnsupportedOperationException): + self.quit(("eSilently",)) + + +class TestQuitRestart(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + + self.pid = self.marionette.process_id + self.profile = self.marionette.profile + self.session_id = self.marionette.session_id + + # Use a preference to check that the restart was successful. If its + # value has not been forced, a restart will cause a reset of it. + self.assertNotEqual( + self.marionette.get_pref("startup.homepage_welcome_url"), "about:about" + ) + self.marionette.set_pref("startup.homepage_welcome_url", "about:about") + + def tearDown(self): + # Ensure to restart a session if none exist for clean-up + if self.marionette.session is None: + self.marionette.start_session() + + self.marionette.clear_pref("startup.homepage_welcome_url") + + MarionetteTestCase.tearDown(self) + + @property + def is_safe_mode(self): + with self.marionette.using_context("chrome"): + return self.marionette.execute_script( + """ + return Services.appinfo.inSafeMode; + """ + ) + + def shutdown(self, restart=False): + self.marionette.set_context("chrome") + self.marionette.execute_script( + """ + let flags = Ci.nsIAppStartup.eAttemptQuit; + if (arguments[0]) { + flags |= Ci.nsIAppStartup.eRestart; + } + Services.startup.quit(flags); + """, + script_args=(restart,), + ) + + def test_force_restart(self): + self.marionette.restart(in_app=False) + self.assertEqual(self.marionette.profile, self.profile) + self.assertNotEqual(self.marionette.session_id, self.session_id) + + # A forced restart will cause a new process id + self.assertNotEqual(self.marionette.process_id, self.pid) + self.assertNotEqual( + self.marionette.get_pref("startup.homepage_welcome_url"), "about:about" + ) + + def test_force_clean_restart(self): + self.marionette.restart(in_app=False, clean=True) + self.assertNotEqual(self.marionette.profile, self.profile) + self.assertNotEqual(self.marionette.session_id, self.session_id) + # A forced restart will cause a new process id + self.assertNotEqual(self.marionette.process_id, self.pid) + self.assertNotEqual( + self.marionette.get_pref("startup.homepage_welcome_url"), "about:about" + ) + + def test_force_quit(self): + self.marionette.quit(in_app=False) + + self.assertEqual(self.marionette.session, None) + with self.assertRaisesRegexp( + errors.InvalidSessionIdException, "Please start a session" + ): + self.marionette.get_url() + + def test_force_clean_quit(self): + self.marionette.quit(in_app=False, clean=True) + + self.assertEqual(self.marionette.session, None) + with self.assertRaisesRegexp( + errors.InvalidSessionIdException, "Please start a session" + ): + self.marionette.get_url() + + self.marionette.start_session() + self.assertNotEqual(self.marionette.profile, self.profile) + self.assertNotEqual(self.marionette.session_id, self.session_id) + self.assertNotEqual( + self.marionette.get_pref("startup.homepage_welcome_url"), "about:about" + ) + + def test_quit_no_in_app_and_clean(self): + # Test that in_app and clean cannot be used in combination + with self.assertRaisesRegexp( + ValueError, "cannot be triggered with the clean flag set" + ): + self.marionette.quit(in_app=True, clean=True) + + def test_restart_no_in_app_and_clean(self): + # Test that in_app and clean cannot be used in combination + with self.assertRaisesRegexp( + ValueError, "cannot be triggered with the clean flag set" + ): + self.marionette.restart(in_app=True, clean=True) + + def test_restart_preserves_requested_capabilities(self): + self.marionette.delete_session() + self.marionette.start_session(capabilities={"test:fooBar": True}) + + self.marionette.restart(in_app=False) + self.assertEqual(self.marionette.session.get("test:fooBar"), True) + + def test_restart_safe_mode(self): + try: + self.assertFalse(self.is_safe_mode, "Safe Mode is unexpectedly enabled") + self.marionette.restart(safe_mode=True) + self.assertTrue(self.is_safe_mode, "Safe Mode is not enabled") + finally: + self.marionette.quit(in_app=False, clean=True) + + def test_restart_safe_mode_requires_in_app(self): + self.assertFalse(self.is_safe_mode, "Safe Mode is unexpectedly enabled") + + with self.assertRaisesRegexp(ValueError, "in_app restart is required"): + self.marionette.restart(in_app=False, safe_mode=True) + + self.assertFalse(self.is_safe_mode, "Safe Mode is unexpectedly enabled") + self.marionette.quit(in_app=False, clean=True) + + def test_in_app_restart(self): + details = self.marionette.restart() + self.assertTrue(details["in_app"], "Expected in_app restart") + self.assertFalse(details["forced"], "Expected non-forced shutdown") + + self.assertEqual(self.marionette.profile, self.profile) + self.assertNotEqual(self.marionette.session_id, self.session_id) + + self.assertNotEqual(self.marionette.process_id, self.pid) + + self.assertNotEqual( + self.marionette.get_pref("startup.homepage_welcome_url"), "about:about" + ) + + def test_in_app_restart_component_prevents_shutdown(self): + with self.marionette.using_context("chrome"): + self.marionette.execute_script( + """ + Services.obs.addObserver(subject => { + let cancelQuit = subject.QueryInterface(Ci.nsISupportsPRBool); + cancelQuit.data = true; + }, "quit-application-requested"); + """ + ) + + details = self.marionette.restart() + self.assertTrue(details["in_app"], "Expected in_app restart") + self.assertTrue(details["forced"], "Expected forced shutdown") + + self.assertEqual(self.marionette.profile, self.profile) + self.assertNotEqual(self.marionette.session_id, self.session_id) + + self.assertNotEqual(self.marionette.process_id, self.pid) + + self.assertNotEqual( + self.marionette.get_pref("startup.homepage_welcome_url"), "about:about" + ) + + def test_in_app_restart_with_callback(self): + details = self.marionette.restart(callback=lambda: self.shutdown(restart=True)) + self.assertTrue(details["in_app"], "Expected in_app restart") + + self.assertEqual(self.marionette.profile, self.profile) + self.assertNotEqual(self.marionette.session_id, self.session_id) + + self.assertNotEqual(self.marionette.process_id, self.pid) + + self.assertNotEqual( + self.marionette.get_pref("startup.homepage_welcome_url"), "about:about" + ) + + def test_in_app_restart_with_non_callable_callback(self): + with self.assertRaisesRegexp(ValueError, "is not callable"): + self.marionette.restart(callback=4) + + self.assertEqual(self.marionette.instance.runner.returncode, None) + self.assertEqual(self.marionette.is_shutting_down, False) + + @unittest.skipIf(sys.platform.startswith("win"), "Bug 1493796") + def test_in_app_restart_with_callback_but_process_quits_instead(self): + try: + timeout_shutdown = self.marionette.shutdown_timeout + timeout_startup = self.marionette.startup_timeout + self.marionette.shutdown_timeout = 5 + self.marionette.startup_timeout = 0 + + with self.assertRaisesRegexp( + IOError, "Process unexpectedly quit without restarting" + ): + self.marionette.restart(callback=lambda: self.shutdown(restart=False)) + finally: + self.marionette.shutdown_timeout = timeout_shutdown + self.marionette.startup_timeout = timeout_startup + + @unittest.skipIf(sys.platform.startswith("win"), "Bug 1493796") + def test_in_app_restart_with_callback_missing_shutdown(self): + try: + timeout_shutdown = self.marionette.shutdown_timeout + timeout_startup = self.marionette.startup_timeout + self.marionette.shutdown_timeout = 5 + self.marionette.startup_timeout = 0 + + with self.assertRaisesRegexp( + IOError, "the connection to Marionette server is lost" + ): + self.marionette.restart(in_app=True, callback=lambda: False) + finally: + self.marionette.shutdown_timeout = timeout_shutdown + self.marionette.startup_timeout = timeout_startup + + def test_in_app_restart_preserves_requested_capabilities(self): + self.marionette.delete_session() + self.marionette.start_session(capabilities={"test:fooBar": True}) + + details = self.marionette.restart() + self.assertTrue(details["in_app"], "Expected in_app restart") + self.assertEqual(self.marionette.session.get("test:fooBar"), True) + + @unittest.skipUnless(sys.platform.startswith("darwin"), "Only supported on MacOS") + def test_in_app_silent_restart_fails_without_windowless_flag_on_mac_os(self): + self.marionette.delete_session() + self.marionette.start_session() + + with self.assertRaises(errors.UnsupportedOperationException): + self.marionette.restart(silent=True) + + @unittest.skipUnless(sys.platform.startswith("darwin"), "Only supported on MacOS") + def test_in_app_silent_restart_windowless_flag_on_mac_os(self): + self.marionette.delete_session() + self.marionette.start_session(capabilities={"moz:windowless": True}) + + self.marionette.restart(silent=True) + self.assertTrue(self.marionette.session_capabilities["moz:windowless"]) + + self.marionette.restart() + self.assertTrue(self.marionette.session_capabilities["moz:windowless"]) + + self.marionette.delete_session() + + @unittest.skipUnless(sys.platform.startswith("darwin"), "Only supported on MacOS") + def test_in_app_silent_restart_requires_in_app(self): + self.marionette.delete_session() + self.marionette.start_session(capabilities={"moz:windowless": True}) + + with self.assertRaisesRegexp(ValueError, "in_app restart is required"): + self.marionette.restart(in_app=False, silent=True) + + self.marionette.delete_session() + + @unittest.skipIf( + sys.platform.startswith("darwin"), "Not supported on other platforms than MacOS" + ) + def test_in_app_silent_restart_windowless_flag_unsupported_platforms(self): + self.marionette.delete_session() + + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session(capabilities={"moz:windowless": True}) + + def test_in_app_quit(self): + details = self.marionette.quit() + self.assertTrue(details["in_app"], "Expected in_app shutdown") + self.assertFalse(details["forced"], "Expected non-forced shutdown") + self.assertEqual(self.marionette.instance.runner.returncode, 0) + + self.assertEqual(self.marionette.session, None) + with self.assertRaisesRegexp( + errors.InvalidSessionIdException, "Please start a session" + ): + self.marionette.get_url() + + self.marionette.start_session() + self.assertEqual(self.marionette.profile, self.profile) + self.assertNotEqual(self.marionette.session_id, self.session_id) + self.assertNotEqual( + self.marionette.get_pref("startup.homepage_welcome_url"), "about:about" + ) + + def test_in_app_quit_forced_because_component_prevents_shutdown(self): + with self.marionette.using_context("chrome"): + self.marionette.execute_script( + """ + Services.obs.addObserver(subject => { + let cancelQuit = subject.QueryInterface(Ci.nsISupportsPRBool); + cancelQuit.data = true; + }, "quit-application-requested"); + """ + ) + + details = self.marionette.quit() + self.assertTrue(details["in_app"], "Expected in_app shutdown") + self.assertTrue(details["forced"], "Expected forced shutdown") + self.assertEqual(self.marionette.instance.runner.returncode, 0) + + self.assertEqual(self.marionette.session, None) + with self.assertRaisesRegexp( + errors.InvalidSessionIdException, "Please start a session" + ): + self.marionette.get_url() + + self.marionette.start_session() + self.assertEqual(self.marionette.profile, self.profile) + self.assertNotEqual(self.marionette.session_id, self.session_id) + self.assertNotEqual( + self.marionette.get_pref("startup.homepage_welcome_url"), "about:about" + ) + + def test_in_app_quit_with_callback(self): + details = self.marionette.quit(callback=self.shutdown) + self.assertTrue(details["in_app"], "Expected in_app shutdown") + self.assertFalse(details["forced"], "Expected non-forced shutdown") + + self.assertEqual(self.marionette.instance.runner.returncode, 0) + self.assertEqual(self.marionette.is_shutting_down, False) + + self.assertEqual(self.marionette.session, None) + with self.assertRaisesRegexp( + errors.InvalidSessionIdException, "Please start a session" + ): + self.marionette.get_url() + + self.marionette.start_session() + self.assertEqual(self.marionette.profile, self.profile) + self.assertNotEqual(self.marionette.session_id, self.session_id) + self.assertNotEqual( + self.marionette.get_pref("startup.homepage_welcome_url"), "about:about" + ) + + def test_in_app_quit_with_non_callable_callback(self): + with self.assertRaisesRegexp(ValueError, "is not callable"): + self.marionette.quit(callback=4) + self.assertEqual(self.marionette.instance.runner.returncode, None) + self.assertEqual(self.marionette.is_shutting_down, False) + + def test_in_app_quit_forced_because_callback_does_not_shutdown(self): + try: + timeout = self.marionette.shutdown_timeout + self.marionette.shutdown_timeout = 5 + + with self.assertRaisesRegexp(IOError, "Process still running"): + self.marionette.quit(in_app=True, callback=lambda: False) + + self.assertNotEqual(self.marionette.instance.runner.returncode, None) + self.assertEqual(self.marionette.is_shutting_down, False) + finally: + self.marionette.shutdown_timeout = timeout + + self.marionette.start_session() + + def test_in_app_quit_with_callback_that_raises_an_exception(self): + def errorneous_callback(): + raise Exception("foo") + + with self.assertRaisesRegexp(Exception, "foo"): + self.marionette.quit(in_app=True, callback=errorneous_callback) + self.assertEqual(self.marionette.instance.runner.returncode, None) + self.assertEqual(self.marionette.is_shutting_down, False) + + self.assertIsNotNone(self.marionette.session) + self.marionette.current_window_handle + + def test_in_app_quit_with_dismissed_beforeunload_prompt(self): + self.marionette.navigate( + inline( + """ + <input type="text"> + <script> + window.addEventListener("beforeunload", function (event) { + event.preventDefault(); + }); + </script> + """ + ) + ) + + self.marionette.find_element(By.TAG_NAME, "input").send_keys("foo") + self.marionette.quit() + self.assertNotEqual(self.marionette.instance.runner.returncode, None) + self.marionette.start_session() + + def test_reset_context_after_quit_by_set_context(self): + # Check that we are in content context which is used by default in + # Marionette + self.assertNotIn( + "chrome://", + self.marionette.get_url(), + "Context does not default to content", + ) + + self.marionette.set_context("chrome") + self.marionette.quit() + self.assertEqual(self.marionette.session, None) + self.marionette.start_session() + self.assertNotIn( + "chrome://", + self.marionette.get_url(), + "Not in content context after quit with using_context", + ) + + def test_reset_context_after_quit_by_using_context(self): + # Check that we are in content context which is used by default in + # Marionette + self.assertNotIn( + "chrome://", + self.marionette.get_url(), + "Context does not default to content", + ) + + with self.marionette.using_context("chrome"): + self.marionette.quit() + self.assertEqual(self.marionette.session, None) + self.marionette.start_session() + self.assertNotIn( + "chrome://", + self.marionette.get_url(), + "Not in content context after quit with using_context", + ) + + def test_keep_context_after_restart_by_set_context(self): + # Check that we are in content context which is used by default in + # Marionette + self.assertNotIn( + "chrome://", self.marionette.get_url(), "Context doesn't default to content" + ) + + # restart while we are in chrome context + self.marionette.set_context("chrome") + self.marionette.restart() + + self.assertNotEqual(self.marionette.process_id, self.pid) + + self.assertIn( + "chrome://", + self.marionette.get_url(), + "Not in chrome context after a restart with set_context", + ) + + def test_keep_context_after_restart_by_using_context(self): + # Check that we are in content context which is used by default in + # Marionette + self.assertNotIn( + "chrome://", + self.marionette.get_url(), + "Context does not default to content", + ) + + # restart while we are in chrome context + with self.marionette.using_context("chrome"): + self.marionette.restart() + + self.assertNotEqual(self.marionette.process_id, self.pid) + + self.assertIn( + "chrome://", + self.marionette.get_url(), + "Not in chrome context after a restart with using_context", + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_reftest.py b/testing/marionette/harness/marionette_harness/tests/unit/test_reftest.py new file mode 100644 index 0000000000..e173e5a963 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_reftest.py @@ -0,0 +1,105 @@ +# 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_driver.errors import UnsupportedOperationException +from marionette_harness import MarionetteTestCase, skip + + +class TestReftest(MarionetteTestCase): + def setUp(self): + super(TestReftest, self).setUp() + + self.original_window = self.marionette.current_window_handle + + self.marionette.set_pref("marionette.log.truncate", False) + self.marionette.set_pref("dom.send_after_paint_to_content", True) + self.marionette.set_pref("widget.gtk.overlay-scrollbars.enabled", False) + + def tearDown(self): + try: + # make sure we've teared down any reftest context + self.marionette._send_message("reftest:teardown", {}) + except UnsupportedOperationException: + # this will throw if we aren't currently in a reftest context + pass + + self.marionette.switch_to_window(self.original_window) + + self.marionette.clear_pref("dom.send_after_paint_to_content") + self.marionette.clear_pref("marionette.log.truncate") + self.marionette.clear_pref("widget.gtk.overlay-scrollbars.enabled") + + super(TestReftest, self).tearDown() + + @skip("Bug 1648444 - Unexpected page unload when refreshing about:blank") + def test_basic(self): + self.marionette._send_message("reftest:setup", {"screenshot": "unexpected"}) + rv = self.marionette._send_message( + "reftest:run", + { + "test": "about:blank", + "references": [["about:blank", [], "=="]], + "expected": "PASS", + "timeout": 10 * 1000, + }, + ) + self.marionette._send_message("reftest:teardown", {}) + expected = { + "value": { + "extra": {}, + "message": "Testing about:blank == about:blank\n", + "stack": None, + "status": "PASS", + } + } + self.assertEqual(expected, rv) + + def test_url_comparison(self): + test_page = self.fixtures.where_is("test.html") + test_page_2 = self.fixtures.where_is("foo/../test.html") + + self.marionette._send_message("reftest:setup", {"screenshot": "unexpected"}) + rv = self.marionette._send_message( + "reftest:run", + { + "test": test_page, + "references": [[test_page_2, [], "=="]], + "expected": "PASS", + "timeout": 10 * 1000, + }, + ) + self.marionette._send_message("reftest:teardown", {}) + self.assertEqual("PASS", rv["value"]["status"]) + + def test_cache_multiple_sizes(self): + teal = self.fixtures.where_is("reftest/teal-700x700.html") + mostly_teal = self.fixtures.where_is("reftest/mostly-teal-700x700.html") + + self.marionette._send_message("reftest:setup", {"screenshot": "unexpected"}) + rv = self.marionette._send_message( + "reftest:run", + { + "test": teal, + "references": [[mostly_teal, [], "=="]], + "expected": "PASS", + "timeout": 10 * 1000, + "width": 600, + "height": 600, + }, + ) + self.assertEqual("PASS", rv["value"]["status"]) + + rv = self.marionette._send_message( + "reftest:run", + { + "test": teal, + "references": [[mostly_teal, [], "=="]], + "expected": "PASS", + "timeout": 10 * 1000, + "width": 700, + "height": 700, + }, + ) + self.assertEqual("FAIL", rv["value"]["status"]) + self.marionette._send_message("reftest:teardown", {}) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_rendered_element.py b/testing/marionette/harness/marionette_harness/tests/unit/test_rendered_element.py new file mode 100644 index 0000000000..8c1d839a1b --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_rendered_element.py @@ -0,0 +1,31 @@ +from six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class RenderedElementTests(MarionetteTestCase): + def test_get_computed_style_value_from_element(self): + self.marionette.navigate( + inline( + """ + <div style="color: green;" id="parent"> + <p id="green">This should be green</p> + <p id="red" style="color: red;">But this is red</p> + </div> + """ + ) + ) + + parent = self.marionette.find_element(By.ID, "parent") + self.assertEqual("rgb(0, 128, 0)", parent.value_of_css_property("color")) + + green = self.marionette.find_element(By.ID, "green") + self.assertEqual("rgb(0, 128, 0)", green.value_of_css_property("color")) + + red = self.marionette.find_element(By.ID, "red") + self.assertEqual("rgb(255, 0, 0)", red.value_of_css_property("color")) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_report.py b/testing/marionette/harness/marionette_harness/tests/unit/test_report.py new file mode 100644 index 0000000000..a876888dee --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_report.py @@ -0,0 +1,27 @@ +# 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_harness import ( + expectedFailure, + MarionetteTestCase, + skip, + unexpectedSuccess, +) + + +class TestReport(MarionetteTestCase): + def test_pass(self): + assert True + + @skip("Skip Message") + def test_skip(self): + assert False + + @expectedFailure + def test_error(self): + raise Exception() + + @unexpectedSuccess + def test_unexpected_pass(self): + assert True diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_run_js_test.py b/testing/marionette/harness/marionette_harness/tests/unit/test_run_js_test.py new file mode 100644 index 0000000000..d180633376 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_run_js_test.py @@ -0,0 +1,10 @@ +# Any copyright is dedicated to the Public Domain. +# http://creativecommons.org/publicdomain/zero/1.0/ + +from marionette_harness import MarionetteTestCase + + +class TestRunJSTest(MarionetteTestCase): + def test_basic(self): + self.run_js_test("test_simpletest_pass.js") + self.run_js_test("test_simpletest_fail.js") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_screen_orientation.py b/testing/marionette/harness/marionette_harness/tests/unit/test_screen_orientation.py new file mode 100644 index 0000000000..0bc641aa3e --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_screen_orientation.py @@ -0,0 +1,75 @@ +# 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_driver import errors +from marionette_driver.wait import Wait +from marionette_harness import ( + MarionetteTestCase, + parameterized, + skip_if_desktop, +) + + +default_orientation = "portrait-primary" +unknown_orientation = "Unknown screen orientation: {}" + + +class TestScreenOrientation(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + + def tearDown(self): + MarionetteTestCase.tearDown(self) + + def wait_for_orientation(self, orientation, timeout=None): + Wait(self.marionette, timeout=timeout).until( + lambda _: self.marionette.orientation == orientation + ) + + @skip_if_desktop("Not supported in Firefox") + @parameterized("landscape-primary", "landscape-primary") + @parameterized("landscape-secondary", "landscape-secondary") + @parameterized("portrait-primary", "portrait-primary") + # @parameterized("portrait-secondary", "portrait-secondary") # Bug 1533084 + def test_set_orientation(self, orientation): + self.marionette.set_orientation(orientation) + self.wait_for_orientation(orientation) + + @skip_if_desktop("Not supported in Firefox") + def test_set_orientation_to_shorthand_portrait(self): + # Set orientation to something other than portrait-primary first, + # since the default is portrait-primary. + self.marionette.set_orientation("landscape-primary") + self.wait_for_orientation("landscape-primary") + + self.marionette.set_orientation("portrait") + self.wait_for_orientation("portrait-primary") + + @skip_if_desktop("Not supported in Firefox") + def test_set_orientation_to_shorthand_landscape(self): + self.marionette.set_orientation("landscape") + self.wait_for_orientation("landscape-primary") + + @skip_if_desktop("Not supported in Firefox") + def test_set_orientation_with_mixed_casing(self): + self.marionette.set_orientation("lAnDsCaPe") + self.wait_for_orientation("landscape-primary") + + @skip_if_desktop("Not supported in Firefox") + def test_set_invalid_orientation(self): + with self.assertRaisesRegexp( + errors.MarionetteException, unknown_orientation.format("cheese") + ): + self.marionette.set_orientation("cheese") + + @skip_if_desktop("Not supported in Firefox") + def test_set_null_orientation(self): + with self.assertRaisesRegexp( + errors.MarionetteException, unknown_orientation.format("null") + ): + self.marionette.set_orientation(None) + + def test_unsupported_operation_on_desktop(self): + with self.assertRaises(errors.UnsupportedOperationException): + self.marionette.set_orientation("landscape-primary") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_screenshot.py b/testing/marionette/harness/marionette_harness/tests/unit/test_screenshot.py new file mode 100644 index 0000000000..bce712b059 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_screenshot.py @@ -0,0 +1,393 @@ +# 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 hashlib +import imghdr +import struct +import tempfile +import unittest + +import six +from six.moves.urllib.parse import quote + +import mozinfo + +from marionette_driver import By +from marionette_driver.errors import NoSuchWindowException +from marionette_harness import ( + MarionetteTestCase, + skip, + WindowManagerMixin, +) + + +def decodebytes(s): + if six.PY3: + return base64.decodebytes(six.ensure_binary(s)) + return base64.decodestring(s) + + +def inline(doc, mime="text/html;charset=utf-8"): + return "data:{0},{1}".format(mime, quote(doc)) + + +box = inline( + "<body><div id='box'><p id='green' style='width: 50px; height: 50px; " + "background: silver;'></p></div></body>" +) +input = inline("<body><input id='text-input'></input></body>") +long = inline("<body style='height: 300vh'><p style='margin-top: 100vh'>foo</p></body>") +short = inline("<body style='height: 10vh'></body>") +svg = inline( + """ + <svg xmlns="http://www.w3.org/2000/svg" height="20" width="20"> + <rect height="20" width="20"/> + </svg>""", + mime="image/svg+xml", +) + + +class ScreenCaptureTestCase(MarionetteTestCase): + def setUp(self): + super(ScreenCaptureTestCase, self).setUp() + + self.maxDiff = None + + self._device_pixel_ratio = None + + # Ensure that each screenshot test runs on a blank page to avoid left + # over elements or focus which could interfer with taking screenshots + self.marionette.navigate("about:blank") + + @property + def device_pixel_ratio(self): + if self._device_pixel_ratio is None: + self._device_pixel_ratio = self.marionette.execute_script( + """ + return window.devicePixelRatio + """ + ) + return self._device_pixel_ratio + + @property + def document_element(self): + return self.marionette.find_element(By.CSS_SELECTOR, ":root") + + @property + def page_y_offset(self): + return self.marionette.execute_script("return window.pageYOffset") + + @property + def viewport_dimensions(self): + return self.marionette.execute_script( + "return [window.innerWidth, window.innerHeight];" + ) + + def assert_png(self, screenshot): + """Test that screenshot is a Base64 encoded PNG file.""" + if six.PY3 and not isinstance(screenshot, bytes): + screenshot = bytes(screenshot, encoding="utf-8") + image = decodebytes(screenshot) + self.assertEqual(imghdr.what("", image), "png") + + def assert_formats(self, element=None): + if element is None: + element = self.document_element + + screenshot_default = self.marionette.screenshot(element=element) + if six.PY3 and not isinstance(screenshot_default, bytes): + screenshot_default = bytes(screenshot_default, encoding="utf-8") + screenshot_image = self.marionette.screenshot(element=element, format="base64") + if six.PY3 and not isinstance(screenshot_image, bytes): + screenshot_image = bytes(screenshot_image, encoding="utf-8") + binary1 = self.marionette.screenshot(element=element, format="binary") + binary2 = self.marionette.screenshot(element=element, format="binary") + hash1 = self.marionette.screenshot(element=element, format="hash") + hash2 = self.marionette.screenshot(element=element, format="hash") + + # Valid data should have been returned + self.assert_png(screenshot_image) + self.assertEqual(imghdr.what("", binary1), "png") + self.assertEqual(screenshot_image, base64.b64encode(binary1)) + self.assertEqual(hash1, hashlib.sha256(screenshot_image).hexdigest()) + + # Different formats produce different data + self.assertNotEqual(screenshot_image, binary1) + self.assertNotEqual(screenshot_image, hash1) + self.assertNotEqual(binary1, hash1) + + # A second capture should be identical + self.assertEqual(screenshot_image, screenshot_default) + self.assertEqual(binary1, binary2) + self.assertEqual(hash1, hash2) + + def get_element_dimensions(self, element): + rect = element.rect + return rect["width"], rect["height"] + + def get_image_dimensions(self, screenshot): + if six.PY3 and not isinstance(screenshot, bytes): + screenshot = bytes(screenshot, encoding="utf-8") + self.assert_png(screenshot) + image = decodebytes(screenshot) + width, height = struct.unpack(">LL", image[16:24]) + return int(width), int(height) + + def scale(self, rect): + return ( + int(rect[0] * self.device_pixel_ratio), + int(rect[1] * self.device_pixel_ratio), + ) + + +class TestScreenCaptureChrome(WindowManagerMixin, ScreenCaptureTestCase): + def setUp(self): + super(TestScreenCaptureChrome, self).setUp() + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + super(TestScreenCaptureChrome, self).tearDown() + + @property + def window_dimensions(self): + return tuple( + self.marionette.execute_script( + """ + let el = document.documentElement; + let rect = el.getBoundingClientRect(); + return [rect.width, rect.height]; + """ + ) + ) + + def open_dialog(self): + return self.open_chrome_window( + "chrome://remote/content/marionette/test_dialog.xhtml" + ) + + def test_capture_different_context(self): + """Check that screenshots in content and chrome are different.""" + with self.marionette.using_context("content"): + screenshot_content = self.marionette.screenshot() + screenshot_chrome = self.marionette.screenshot() + self.assertNotEqual(screenshot_content, screenshot_chrome) + + def test_capture_element(self): + dialog = self.open_dialog() + self.marionette.switch_to_window(dialog) + + # Ensure we only capture the element + el = self.marionette.find_element(By.ID, "test-list") + screenshot_element = self.marionette.screenshot(element=el) + self.assertEqual( + self.scale(self.get_element_dimensions(el)), + self.get_image_dimensions(screenshot_element), + ) + + # Ensure we do not capture the full window + screenshot_dialog = self.marionette.screenshot() + self.assertNotEqual(screenshot_dialog, screenshot_element) + + self.marionette.close_chrome_window() + self.marionette.switch_to_window(self.start_window) + + def test_capture_full_area(self): + dialog = self.open_dialog() + self.marionette.switch_to_window(dialog) + + root_dimensions = self.scale(self.get_element_dimensions(self.document_element)) + + # self.marionette.set_window_rect(width=100, height=100) + # A full capture is not the outer dimensions of the window, + # but instead the bounding box of the window's root node (documentElement). + screenshot_full = self.marionette.screenshot() + screenshot_root = self.marionette.screenshot(element=self.document_element) + + self.marionette.close_chrome_window() + self.marionette.switch_to_window(self.start_window) + + self.assert_png(screenshot_full) + self.assert_png(screenshot_root) + self.assertEqual(root_dimensions, self.get_image_dimensions(screenshot_full)) + self.assertEqual(screenshot_root, screenshot_full) + + def test_capture_window_already_closed(self): + dialog = self.open_dialog() + self.marionette.switch_to_window(dialog) + self.marionette.close_chrome_window() + + self.assertRaises(NoSuchWindowException, self.marionette.screenshot) + self.marionette.switch_to_window(self.start_window) + + def test_formats(self): + dialog = self.open_dialog() + self.marionette.switch_to_window(dialog) + + self.assert_formats() + + self.marionette.close_chrome_window() + self.marionette.switch_to_window(self.start_window) + + def test_format_unknown(self): + with self.assertRaises(ValueError): + self.marionette.screenshot(format="cheese") + + +class TestScreenCaptureContent(WindowManagerMixin, ScreenCaptureTestCase): + def setUp(self): + super(TestScreenCaptureContent, self).setUp() + self.marionette.set_context("content") + + def tearDown(self): + self.close_all_tabs() + super(TestScreenCaptureContent, self).tearDown() + + @property + def scroll_dimensions(self): + return tuple( + self.marionette.execute_script( + """ + return [ + document.documentElement.scrollWidth, + document.documentElement.scrollHeight + ]; + """ + ) + ) + + def test_capture_tab_already_closed(self): + new_tab = self.open_tab() + self.marionette.switch_to_window(new_tab) + self.marionette.close() + + self.assertRaises(NoSuchWindowException, self.marionette.screenshot) + self.marionette.switch_to_window(self.start_tab) + + @unittest.skipIf(mozinfo.info["bits"] == 32, "Bug 1582973 - Risk for OOM on 32bit") + def test_capture_vertical_bounds(self): + self.marionette.navigate(inline("<body style='margin-top: 32768px'>foo")) + screenshot = self.marionette.screenshot() + self.assert_png(screenshot) + + @unittest.skipIf(mozinfo.info["bits"] == 32, "Bug 1582973 - Risk for OOM on 32bit") + def test_capture_horizontal_bounds(self): + self.marionette.navigate(inline("<body style='margin-left: 32768px'>foo")) + screenshot = self.marionette.screenshot() + self.assert_png(screenshot) + + @unittest.skipIf(mozinfo.info["bits"] == 32, "Bug 1582973 - Risk for OOM on 32bit") + def test_capture_area_bounds(self): + self.marionette.navigate( + inline("<body style='margin-right: 21747px; margin-top: 21747px'>foo") + ) + screenshot = self.marionette.screenshot() + self.assert_png(screenshot) + + def test_capture_element(self): + self.marionette.navigate(box) + el = self.marionette.find_element(By.TAG_NAME, "div") + screenshot = self.marionette.screenshot(element=el) + self.assert_png(screenshot) + self.assertEqual( + self.scale(self.get_element_dimensions(el)), + self.get_image_dimensions(screenshot), + ) + + @skip("Bug 1213875") + def test_capture_element_scrolled_into_view(self): + self.marionette.navigate(long) + el = self.marionette.find_element(By.TAG_NAME, "p") + screenshot = self.marionette.screenshot(element=el) + self.assert_png(screenshot) + self.assertEqual( + self.scale(self.get_element_dimensions(el)), + self.get_image_dimensions(screenshot), + ) + self.assertGreater(self.page_y_offset, 0) + + def test_capture_full_html_document_element(self): + self.marionette.navigate(long) + screenshot = self.marionette.screenshot() + self.assert_png(screenshot) + self.assertEqual( + self.scale(self.scroll_dimensions), self.get_image_dimensions(screenshot) + ) + + def test_capture_full_svg_document_element(self): + self.marionette.navigate(svg) + screenshot = self.marionette.screenshot() + self.assert_png(screenshot) + self.assertEqual( + self.scale(self.scroll_dimensions), self.get_image_dimensions(screenshot) + ) + + def test_capture_viewport(self): + url = self.marionette.absolute_url("clicks.html") + self.marionette.navigate(short) + self.marionette.navigate(url) + screenshot = self.marionette.screenshot(full=False) + self.assert_png(screenshot) + self.assertEqual( + self.scale(self.viewport_dimensions), self.get_image_dimensions(screenshot) + ) + + def test_capture_viewport_after_scroll(self): + self.marionette.navigate(long) + before = self.marionette.screenshot() + el = self.marionette.find_element(By.TAG_NAME, "p") + self.marionette.execute_script( + "arguments[0].scrollIntoView()", script_args=[el] + ) + after = self.marionette.screenshot(full=False) + self.assertNotEqual(before, after) + self.assertGreater(self.page_y_offset, 0) + + def test_formats(self): + self.marionette.navigate(box) + + # Use a smaller region to speed up the test + element = self.marionette.find_element(By.TAG_NAME, "div") + self.assert_formats(element=element) + + def test_format_unknown(self): + with self.assertRaises(ValueError): + self.marionette.screenshot(format="cheese") + + def test_save_screenshot(self): + expected = self.marionette.screenshot(format="binary") + with tempfile.TemporaryFile("w+b") as fh: + self.marionette.save_screenshot(fh) + fh.flush() + fh.seek(0) + content = fh.read() + self.assertEqual(expected, content) + + def test_scroll_default(self): + self.marionette.navigate(long) + before = self.page_y_offset + el = self.marionette.find_element(By.TAG_NAME, "p") + self.marionette.screenshot(element=el, format="hash") + self.assertNotEqual(before, self.page_y_offset) + + def test_scroll(self): + self.marionette.navigate(long) + before = self.page_y_offset + el = self.marionette.find_element(By.TAG_NAME, "p") + self.marionette.screenshot(element=el, format="hash", scroll=True) + self.assertNotEqual(before, self.page_y_offset) + + def test_scroll_off(self): + self.marionette.navigate(long) + el = self.marionette.find_element(By.TAG_NAME, "p") + before = self.page_y_offset + self.marionette.screenshot(element=el, format="hash", scroll=False) + self.assertEqual(before, self.page_y_offset) + + def test_scroll_no_element(self): + self.marionette.navigate(long) + before = self.page_y_offset + self.marionette.screenshot(format="hash", scroll=True) + self.assertEqual(before, self.page_y_offset) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_select.py b/testing/marionette/harness/marionette_harness/tests/unit/test_select.py new file mode 100644 index 0000000000..60cd94c870 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_select.py @@ -0,0 +1,218 @@ +# 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 six.moves.urllib.parse import quote + +from marionette_driver.by import By + +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class SelectTestCase(MarionetteTestCase): + def assertSelected(self, option_element): + self.assertTrue(option_element.is_selected(), "<option> element not selected") + self.assertTrue( + self.marionette.execute_script( + "return arguments[0].selected", + script_args=[option_element], + sandbox=None, + ), + "<option> selected attribute not updated", + ) + + def assertNotSelected(self, option_element): + self.assertFalse(option_element.is_selected(), "<option> is selected") + self.assertFalse( + self.marionette.execute_script( + "return arguments[0].selected", + script_args=[option_element], + sandbox=None, + ), + "<option> selected attribute not updated", + ) + + +class TestSelect(SelectTestCase): + def test_single(self): + self.marionette.navigate( + inline( + """ + <select> + <option>first + <option>second + </select>""" + ) + ) + select = self.marionette.find_element(By.TAG_NAME, "select") + options = self.marionette.find_elements(By.TAG_NAME, "option") + + self.assertSelected(options[0]) + options[1].click() + self.assertSelected(options[1]) + + def test_deselect_others(self): + self.marionette.navigate( + inline( + """ + <select> + <option>first + <option>second + <option>third + </select>""" + ) + ) + select = self.marionette.find_element(By.TAG_NAME, "select") + options = self.marionette.find_elements(By.TAG_NAME, "option") + + options[0].click() + self.assertSelected(options[0]) + options[1].click() + self.assertSelected(options[1]) + options[2].click() + self.assertSelected(options[2]) + options[0].click() + self.assertSelected(options[0]) + + def test_select_self(self): + self.marionette.navigate( + inline( + """ + <select> + <option>first + <option>second + </select>""" + ) + ) + select = self.marionette.find_element(By.TAG_NAME, "select") + options = self.marionette.find_elements(By.TAG_NAME, "option") + self.assertSelected(options[0]) + self.assertNotSelected(options[1]) + + options[1].click() + self.assertSelected(options[1]) + options[1].click() + self.assertSelected(options[1]) + + def test_out_of_view(self): + self.marionette.navigate( + inline( + """ + <select> + <option>1 + <option>2 + <option>3 + <option>4 + <option>5 + <option>6 + <option>7 + <option>8 + <option>9 + <option>10 + <option>11 + <option>12 + <option>13 + <option>14 + <option>15 + <option>16 + <option>17 + <option>18 + <option>19 + <option>20 + </select>""" + ) + ) + select = self.marionette.find_element(By.TAG_NAME, "select") + options = self.marionette.find_elements(By.TAG_NAME, "option") + + options[14].click() + self.assertSelected(options[14]) + + +class TestSelectMultiple(SelectTestCase): + def test_single(self): + self.marionette.navigate(inline("<select multiple> <option>first </select>")) + option = self.marionette.find_element(By.TAG_NAME, "option") + option.click() + self.assertSelected(option) + + def test_multiple(self): + self.marionette.navigate( + inline( + """ + <select multiple> + <option>first + <option>second + <option>third + </select>""" + ) + ) + select = self.marionette.find_element(By.TAG_NAME, "select") + options = select.find_elements(By.TAG_NAME, "option") + + options[1].click() + self.assertSelected(options[1]) + + options[2].click() + self.assertSelected(options[2]) + self.assertSelected(options[1]) + + def test_deselect_selected(self): + self.marionette.navigate(inline("<select multiple> <option>first </select>")) + option = self.marionette.find_element(By.TAG_NAME, "option") + option.click() + self.assertSelected(option) + option.click() + self.assertNotSelected(option) + + def test_deselect_preselected(self): + self.marionette.navigate( + inline( + """ + <select multiple> + <option selected>first + </select>""" + ) + ) + option = self.marionette.find_element(By.TAG_NAME, "option") + self.assertSelected(option) + option.click() + self.assertNotSelected(option) + + def test_out_of_view(self): + self.marionette.navigate( + inline( + """ + <select multiple> + <option>1 + <option>2 + <option>3 + <option>4 + <option>5 + <option>6 + <option>7 + <option>8 + <option>9 + <option>10 + <option>11 + <option>12 + <option>13 + <option>14 + <option>15 + <option>16 + <option>17 + <option>18 + <option>19 + <option>20 + </select>""" + ) + ) + select = self.marionette.find_element(By.TAG_NAME, "select") + options = self.marionette.find_elements(By.TAG_NAME, "option") + + options[-1].click() + self.assertSelected(options[-1]) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_sendkeys_menupopup_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_sendkeys_menupopup_chrome.py new file mode 100644 index 0000000000..a921b37b85 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_sendkeys_menupopup_chrome.py @@ -0,0 +1,106 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from six.moves.urllib.parse import quote + +from marionette_driver import By, errors, Wait +from marionette_driver.keys import Keys + +from marionette_harness import ( + MarionetteTestCase, + WindowManagerMixin, +) + + +class TestSendkeysMenupopup(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestSendkeysMenupopup, self).setUp() + + self.marionette.set_context("chrome") + new_window = self.open_chrome_window( + "chrome://remote/content/marionette/test_menupopup.xhtml" + ) + self.marionette.switch_to_window(new_window) + + self.click_el = self.marionette.find_element(By.ID, "options-button") + self.disabled_menuitem_el = self.marionette.find_element( + By.ID, "option-disabled" + ) + self.hidden_menuitem_el = self.marionette.find_element(By.ID, "option-hidden") + self.menuitem_el = self.marionette.find_element(By.ID, "option-enabled") + self.menupopup_el = self.marionette.find_element(By.ID, "options-menupopup") + self.testwindow_el = self.marionette.find_element(By.ID, "test-window") + + def context_menu_state(self): + return self.menupopup_el.get_property("state") + + def open_context_menu(self): + def attempt_open_context_menu(): + self.assertEqual(self.context_menu_state(), "closed") + self.click_el.click() + Wait(self.marionette).until( + lambda _: self.context_menu_state() == "open", + message="Context menu did not open", + ) + + try: + attempt_open_context_menu() + except errors.TimeoutException: + # If the first attempt timed out, try a second time. + # On Linux, the test will intermittently fail if we click too + # early on the button. Retrying fixes the issue. See Bug 1686769. + attempt_open_context_menu() + + def wait_for_context_menu_closed(self): + Wait(self.marionette).until( + lambda _: self.context_menu_state() == "closed", + message="Context menu did not close", + ) + + def tearDown(self): + try: + self.close_all_windows() + finally: + super(TestSendkeysMenupopup, self).tearDown() + + def test_sendkeys_menuitem(self): + # Try closing the context menu by sending ESCAPE to a visible context menu item. + self.open_context_menu() + + self.menuitem_el.send_keys(Keys.ESCAPE) + self.wait_for_context_menu_closed() + + def test_sendkeys_menupopup(self): + # Try closing the context menu by sending ESCAPE to the context menu. + self.open_context_menu() + + self.menupopup_el.send_keys(Keys.ESCAPE) + self.wait_for_context_menu_closed() + + def test_sendkeys_window(self): + # Try closing the context menu by sending ESCAPE to the main window. + self.open_context_menu() + + self.testwindow_el.send_keys(Keys.ESCAPE) + self.wait_for_context_menu_closed() + + def test_sendkeys_closed_menu(self): + # send_keys should throw for the menupopup if the contextmenu is closed. + with self.assertRaises(errors.ElementNotInteractableException): + self.menupopup_el.send_keys(Keys.ESCAPE) + + # send_keys should throw for the menuitem if the contextmenu is closed. + with self.assertRaises(errors.ElementNotInteractableException): + self.menuitem_el.send_keys(Keys.ESCAPE) + + def test_sendkeys_hidden_disabled_menuitem(self): + self.open_context_menu() + + # send_keys should throw for a disabled menuitem in an opened contextmenu. + with self.assertRaises(errors.ElementNotInteractableException): + self.disabled_menuitem_el.send_keys(Keys.ESCAPE) + + # send_keys should throw for a hidden menuitem in an opened contextmenu. + with self.assertRaises(errors.ElementNotInteractableException): + self.hidden_menuitem_el.send_keys(Keys.ESCAPE) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_session.py b/testing/marionette/harness/marionette_harness/tests/unit/test_session.py new file mode 100644 index 0000000000..1b709ed28b --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_session.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 six + +from marionette_driver import errors + +from marionette_harness import MarionetteTestCase + + +class TestSession(MarionetteTestCase): + def setUp(self): + super(TestSession, self).setUp() + + self.marionette.delete_session() + + def test_new_session_returns_capabilities(self): + # Sends newSession + caps = self.marionette.start_session() + + # Check that session was created. This implies the server + # sent us the sessionId and status fields. + self.assertIsNotNone(self.marionette.session) + + # Required capabilities mandated by WebDriver spec + self.assertIn("browserName", caps) + self.assertIn("browserVersion", caps) + self.assertIn("platformName", caps) + + def test_get_session_id(self): + # Sends newSession + self.marionette.start_session() + + self.assertTrue(self.marionette.session_id is not None) + self.assertTrue(isinstance(self.marionette.session_id, six.text_type)) + + def test_session_already_started(self): + self.marionette.start_session() + self.assertTrue(isinstance(self.marionette.session_id, six.text_type)) + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette._send_message("WebDriver:NewSession", {}) + + def test_no_session(self): + with self.assertRaises(errors.InvalidSessionIdException): + self.marionette.get_url() + + self.marionette.start_session() + self.marionette.get_url() diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_shadowroot_findelement.py b/testing/marionette/harness/marionette_harness/tests/unit/test_shadowroot_findelement.py new file mode 100644 index 0000000000..de5dfdb91a --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_shadowroot_findelement.py @@ -0,0 +1,113 @@ +from six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_driver.errors import ( + DetachedShadowRootException, + NoSuchElementException, + NoSuchShadowRootException, +) +from marionette_driver.marionette import WebElement, ShadowRoot + +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +page_shadow_dom = inline( + """ + <custom-element></custom-element> + <script> + customElements.define('custom-element', + class extends HTMLElement { + constructor() { + super(); + this.attachShadow({mode: 'open'}).innerHTML = ` + <div><a href=# id=foo>full link text</a><a href=# id=bar>another link text</a></div> + `; + } + }); + </script>""" +) + + +class TestShadowDOMFindElement(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + self.marionette.timeout.implicit = 0 + + def test_find_element_from_shadow_root(self): + self.marionette.navigate(page_shadow_dom) + custom_element = self.marionette.find_element(By.CSS_SELECTOR, "custom-element") + shadow_root = custom_element.shadow_root + found = shadow_root.find_element(By.CSS_SELECTOR, "a") + self.assertIsInstance(found, WebElement) + + el = self.marionette.execute_script( + """ + return arguments[0].shadowRoot.querySelector('a') + """, + [custom_element], + ) + self.assertEqual(found, el) + + def test_unknown_element_from_shadow_root(self): + self.marionette.navigate(page_shadow_dom) + shadow_root = self.marionette.find_element( + By.CSS_SELECTOR, "custom-element" + ).shadow_root + with self.assertRaises(NoSuchElementException): + shadow_root.find_element(By.CSS_SELECTOR, "does not exist") + + def test_detached_shadow_root(self): + self.marionette.navigate(page_shadow_dom) + shadow_root = self.marionette.find_element( + By.CSS_SELECTOR, "custom-element" + ).shadow_root + self.marionette.refresh() + with self.assertRaises(DetachedShadowRootException): + shadow_root.find_element(By.CSS_SELECTOR, "a") + + def test_no_such_shadow_root(self): + not_existing_shadow_root = ShadowRoot(self.marionette, "foo") + with self.assertRaises(NoSuchShadowRootException): + not_existing_shadow_root.find_element(By.CSS_SELECTOR, "a") + + +class TestShadowDOMFindElements(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + self.marionette.timeout.implicit = 0 + + def test_find_elements_from_shadow_root(self): + self.marionette.navigate(page_shadow_dom) + custom_element = self.marionette.find_element(By.CSS_SELECTOR, "custom-element") + shadow_root = custom_element.shadow_root + found = shadow_root.find_elements(By.CSS_SELECTOR, "a") + self.assertEqual(len(found), 2) + + els = self.marionette.execute_script( + """ + return arguments[0].shadowRoot.querySelectorAll('a') + """, + [custom_element], + ) + + for i in range(len(found)): + self.assertIsInstance(found[i], WebElement) + self.assertEqual(found[i], els[i]) + + def test_detached_shadow_root(self): + self.marionette.navigate(page_shadow_dom) + shadow_root = self.marionette.find_element( + By.CSS_SELECTOR, "custom-element" + ).shadow_root + self.marionette.refresh() + with self.assertRaises(DetachedShadowRootException): + shadow_root.find_elements(By.CSS_SELECTOR, "a") + + def test_no_such_shadow_root(self): + not_existing_shadow_root = ShadowRoot(self.marionette, "foo") + with self.assertRaises(NoSuchShadowRootException): + not_existing_shadow_root.find_elements(By.CSS_SELECTOR, "a") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_skip_setup.py b/testing/marionette/harness/marionette_harness/tests/unit/test_skip_setup.py new file mode 100644 index 0000000000..e3cac5947f --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_skip_setup.py @@ -0,0 +1,33 @@ +# 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_harness import MarionetteTestCase, SkipTest + + +class TestSetUpSkipped(MarionetteTestCase): + testVar = {"test": "SkipTest"} + + def setUp(self): + MarionetteTestCase.setUp(self) + try: + self.testVar["email"] + except KeyError: + raise SkipTest("email key not present in dict, skip ...") + + def test_assert(self): + assert True + + +class TestSetUpNotSkipped(MarionetteTestCase): + testVar = {"test": "SkipTest"} + + def setUp(self): + try: + self.testVar["test"] + except KeyError: + raise SkipTest("email key not present in dict, skip ...") + MarionetteTestCase.setUp(self) + + def test_assert(self): + assert True diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame.py b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame.py new file mode 100644 index 0000000000..04cb9ce2f7 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame.py @@ -0,0 +1,96 @@ +# 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_driver.by import By +from marionette_driver.errors import InvalidArgumentException, NoSuchFrameException + +from marionette_harness import MarionetteTestCase + + +class TestSwitchFrame(MarionetteTestCase): + def setUp(self): + super(TestSwitchFrame, self).setUp() + + test_html = self.marionette.absolute_url("frameset.html") + self.marionette.navigate(test_html) + + def test_exceptions(self): + frame = self.marionette.find_element(By.CSS_SELECTOR, ":root") + with self.assertRaises(NoSuchFrameException): + self.marionette.switch_to_frame(frame) + + with self.assertRaises(InvalidArgumentException): + self.marionette.switch_to_frame(-1) + + def test_by_frame_element(self): + frame = self.marionette.find_element(By.NAME, "third") + self.marionette.switch_to_frame(frame) + + element = self.marionette.find_element(By.ID, "email") + self.assertEqual(element.get_attribute("type"), "email") + + def test_by_index(self): + self.marionette.switch_to_frame(2) + + element = self.marionette.find_element(By.ID, "email") + self.assertEqual(element.get_attribute("type"), "email") + + def test_back_to_top_frame(self): + frame1 = self.marionette.find_element(By.ID, "sixth") + self.marionette.switch_to_frame(frame1) + self.marionette.switch_to_frame(0) + + self.marionette.find_element(By.ID, "testDiv") + + self.marionette.switch_to_frame() + frame = self.marionette.find_element(By.ID, "sixth") + self.assertEqual(frame, frame1) + + +class TestSwitchParentFrame(MarionetteTestCase): + def test_iframe(self): + frame_html = self.marionette.absolute_url("test_iframe.html") + self.marionette.navigate(frame_html) + + frame = self.marionette.find_element(By.ID, "test_iframe") + self.marionette.switch_to_frame(frame) + self.marionette.find_element(By.ID, "testDiv") + + self.marionette.switch_to_parent_frame() + + self.marionette.find_element(By.ID, "test_iframe") + + def test_frameset(self): + frame_html = self.marionette.absolute_url("frameset.html") + self.marionette.navigate(frame_html) + frame = self.marionette.find_element(By.NAME, "third") + self.marionette.switch_to_frame(frame) + + # If we don't find the following element we aren't on the right page + self.marionette.find_element(By.ID, "checky") + + self.marionette.switch_to_parent_frame() + self.marionette.find_element(By.NAME, "third") + + def test_from_default_context_is_a_noop(self): + formpage = self.marionette.absolute_url("formPage.html") + self.marionette.navigate(formpage) + self.marionette.find_element(By.ID, "checky") + + self.marionette.switch_to_parent_frame() + self.marionette.find_element(By.ID, "checky") + + def test_from_second_level(self): + frame_html = self.marionette.absolute_url("frameset.html") + self.marionette.navigate(frame_html) + frame = self.marionette.find_element(By.NAME, "fourth") + self.marionette.switch_to_frame(frame) + + second_level = self.marionette.find_element(By.NAME, "child1") + self.marionette.switch_to_frame(second_level) + self.marionette.find_element(By.NAME, "myCheckBox") + + self.marionette.switch_to_parent_frame() + + second_level = self.marionette.find_element(By.NAME, "child1") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame_chrome.py new file mode 100644 index 0000000000..369ea0c061 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame_chrome.py @@ -0,0 +1,57 @@ +# 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_driver import By +from marionette_driver.errors import JavascriptException + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestSwitchFrameChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestSwitchFrameChrome, self).setUp() + self.marionette.set_context("chrome") + + new_window = self.open_chrome_window( + "chrome://remote/content/marionette/test.xhtml" + ) + self.marionette.switch_to_window(new_window) + self.assertNotEqual( + self.start_window, self.marionette.current_chrome_window_handle + ) + + def tearDown(self): + self.close_all_windows() + super(TestSwitchFrameChrome, self).tearDown() + + def test_switch_simple(self): + self.assertIn( + "test.xhtml", self.marionette.get_url(), "Initial navigation has failed" + ) + self.marionette.switch_to_frame(0) + self.assertIn( + "test.xhtml", self.marionette.get_url(), "Switching by index failed" + ) + self.marionette.find_element(By.ID, "testBox") + self.marionette.switch_to_frame() + self.assertIn( + "test.xhtml", self.marionette.get_url(), "Switching by null failed" + ) + iframe = self.marionette.find_element(By.ID, "iframe") + self.marionette.switch_to_frame(iframe) + self.assertIn( + "test.xhtml", self.marionette.get_url(), "Switching by element failed" + ) + self.marionette.find_element(By.ID, "testBox") + + def test_stack_trace(self): + self.assertIn( + "test.xhtml", self.marionette.get_url(), "Initial navigation has failed" + ) + self.marionette.switch_to_frame(0) + self.marionette.find_element(By.ID, "testBox") + try: + self.marionette.execute_async_script("foo();") + except JavascriptException as e: + self.assertIn("foo", str(e)) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_chrome.py new file mode 100644 index 0000000000..0b02e45351 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_chrome.py @@ -0,0 +1,113 @@ +# 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 + +from unittest import skipIf + +# add this directory to the path +sys.path.append(os.path.dirname(__file__)) + +from test_switch_window_content import TestSwitchToWindowContent + + +class TestSwitchWindowChrome(TestSwitchToWindowContent): + def setUp(self): + super(TestSwitchWindowChrome, self).setUp() + + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + + super(TestSwitchWindowChrome, self).tearDown() + + def test_switch_to_unloaded_tab(self): + # Can only run in content context + pass + + @skipIf( + sys.platform.startswith("linux"), + "Bug 1511970 - New window isn't moved to the background on Linux", + ) + def test_switch_tabs_for_new_background_window_without_focus_change(self): + # Open an additional tab in the original window so we can better check + # the selected index in thew new window to be opened. + second_tab = self.open_tab(focus=True) + self.marionette.switch_to_window(second_tab, focus=True) + second_tab_index = self.get_selected_tab_index() + self.assertNotEqual(second_tab_index, self.selected_tab_index) + + # Open a new background window, but we are interested in the tab + with self.marionette.using_context("content"): + tab_in_new_window = self.open_window() + self.assertEqual(self.marionette.current_window_handle, second_tab) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + self.assertEqual(self.get_selected_tab_index(), second_tab_index) + + # Switch to the tab in the new window but don't focus it + self.marionette.switch_to_window(tab_in_new_window, focus=False) + self.assertEqual(self.marionette.current_window_handle, tab_in_new_window) + self.assertNotEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + self.assertEqual(self.get_selected_tab_index(), second_tab_index) + + def test_switch_tabs_for_new_foreground_window_with_focus_change(self): + # Open an addition tab in the original window so we can better check + # the selected index in thew new window to be opened. + second_tab = self.open_tab() + self.marionette.switch_to_window(second_tab, focus=True) + second_tab_index = self.get_selected_tab_index() + self.assertNotEqual(second_tab_index, self.selected_tab_index) + + # Opens a new window, but we are interested in the tab + with self.marionette.using_context("content"): + tab_in_new_window = self.open_window(focus=True) + self.assertEqual(self.marionette.current_window_handle, second_tab) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + self.assertNotEqual(self.get_selected_tab_index(), second_tab_index) + + self.marionette.switch_to_window(tab_in_new_window) + self.assertEqual(self.marionette.current_window_handle, tab_in_new_window) + self.assertNotEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + self.assertNotEqual(self.get_selected_tab_index(), second_tab_index) + + self.marionette.switch_to_window(second_tab, focus=True) + self.assertEqual(self.marionette.current_window_handle, second_tab) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + # Bug 1335085 - The focus doesn't change even as requested so. + # self.assertEqual(self.get_selected_tab_index(), second_tab_index) + + def test_switch_tabs_for_new_foreground_window_without_focus_change(self): + # Open an addition tab in the original window so we can better check + # the selected index in thew new window to be opened. + second_tab = self.open_tab() + self.marionette.switch_to_window(second_tab, focus=True) + second_tab_index = self.get_selected_tab_index() + self.assertNotEqual(second_tab_index, self.selected_tab_index) + + self.open_window(focus=True) + self.assertEqual(self.marionette.current_window_handle, second_tab) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + self.assertNotEqual(self.get_selected_tab_index(), second_tab_index) + + # Switch to the second tab in the first window, but don't focus it. + self.marionette.switch_to_window(second_tab, focus=False) + self.assertEqual(self.marionette.current_window_handle, second_tab) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + self.assertNotEqual(self.get_selected_tab_index(), second_tab_index) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_content.py b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_content.py new file mode 100644 index 0000000000..8cd7c8ed1e --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_content.py @@ -0,0 +1,258 @@ +# This Source Code Form is subject to the terms of the Mozilla ublic +# 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 sys +from unittest import skipIf + +from six.moves.urllib.parse import quote + +from marionette_driver import By, Wait +from marionette_driver.keys import Keys + +from marionette_harness import ( + MarionetteTestCase, + WindowManagerMixin, +) + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestSwitchToWindowContent(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestSwitchToWindowContent, self).setUp() + + if self.marionette.session_capabilities["platformName"] == "mac": + self.mod_key = Keys.META + else: + self.mod_key = Keys.CONTROL + + self.selected_tab_index = self.get_selected_tab_index() + + def tearDown(self): + self.close_all_tabs() + + super(TestSwitchToWindowContent, self).tearDown() + + def get_selected_tab_index(self): + with self.marionette.using_context("chrome"): + return self.marionette.execute_script( + """ + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + let win = null; + + if (AppConstants.MOZ_APP_NAME == "fennec") { + win = Services.wm.getMostRecentWindow("navigator:browser"); + } else { + const { BrowserWindowTracker } = ChromeUtils.importESModule( + "resource:///modules/BrowserWindowTracker.sys.mjs" + ); + win = BrowserWindowTracker.getTopWindow(); + } + + let tabBrowser = null; + + // Fennec + if (win.BrowserApp) { + tabBrowser = win.BrowserApp; + + // Firefox + } else if (win.gBrowser) { + tabBrowser = win.gBrowser; + + } else { + return null; + } + + for (let i = 0; i < tabBrowser.tabs.length; i++) { + if (tabBrowser.tabs[i] == tabBrowser.selectedTab) { + return i; + } + } + """ + ) + + def test_switch_tabs_with_focus_change(self): + new_tab = self.open_tab(focus=True) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertNotEqual(self.get_selected_tab_index(), self.selected_tab_index) + + # Switch to new tab first because it is already selected + self.marionette.switch_to_window(new_tab) + self.assertEqual(self.marionette.current_window_handle, new_tab) + self.assertNotEqual(self.get_selected_tab_index(), self.selected_tab_index) + + # Switch to original tab by explicitely setting the focus + self.marionette.switch_to_window(self.start_tab, focus=True) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertEqual(self.get_selected_tab_index(), self.selected_tab_index) + + self.marionette.switch_to_window(new_tab) + self.marionette.close() + + self.marionette.switch_to_window(self.start_tab) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertEqual(self.get_selected_tab_index(), self.selected_tab_index) + + @skipIf( + sys.platform.startswith("linux"), + "Bug 1557232 - Original window sometimes doesn't receive focus", + ) + def test_switch_tabs_in_different_windows_with_focus_change(self): + new_tab1 = self.open_tab(focus=True) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertEqual(self.get_selected_tab_index(), 1) + + # Switch to new tab first which is already selected + self.marionette.switch_to_window(new_tab1) + self.assertEqual(self.marionette.current_window_handle, new_tab1) + self.assertEqual(self.get_selected_tab_index(), 1) + + # Open a new browser window with a single focused tab already focused + with self.marionette.using_context("content"): + new_tab2 = self.open_window(focus=True) + self.assertEqual(self.marionette.current_window_handle, new_tab1) + self.assertEqual(self.get_selected_tab_index(), 0) + + # Switch to that tab + self.marionette.switch_to_window(new_tab2) + self.assertEqual(self.marionette.current_window_handle, new_tab2) + self.assertEqual(self.get_selected_tab_index(), 0) + + # Switch back to the 2nd tab of the original window and setting the focus + self.marionette.switch_to_window(new_tab1, focus=True) + self.assertEqual(self.marionette.current_window_handle, new_tab1) + self.assertEqual(self.get_selected_tab_index(), 1) + + self.marionette.switch_to_window(new_tab2) + self.marionette.close() + + self.marionette.switch_to_window(new_tab1) + self.marionette.close() + + self.marionette.switch_to_window(self.start_tab) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertEqual(self.get_selected_tab_index(), self.selected_tab_index) + + def test_switch_tabs_without_focus_change(self): + new_tab = self.open_tab(focus=True) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertNotEqual(self.get_selected_tab_index(), self.selected_tab_index) + + # Switch to new tab first because it is already selected + self.marionette.switch_to_window(new_tab) + self.assertEqual(self.marionette.current_window_handle, new_tab) + + # Switch to original tab by explicitely not setting the focus + self.marionette.switch_to_window(self.start_tab, focus=False) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertNotEqual(self.get_selected_tab_index(), self.selected_tab_index) + + self.marionette.switch_to_window(new_tab) + self.marionette.close() + + self.marionette.switch_to_window(self.start_tab) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertEqual(self.get_selected_tab_index(), self.selected_tab_index) + + def test_switch_to_unloaded_tab(self): + first_page = inline("<p>foo") + second_page = inline("<p>bar") + + self.assertEqual(len(self.marionette.window_handles), 1) + self.marionette.navigate(first_page) + + new_tab = self.open_tab() + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertEqual(self.get_selected_tab_index(), self.selected_tab_index) + + self.marionette.switch_to_window(new_tab) + self.assertEqual(self.marionette.current_window_handle, new_tab) + self.assertNotEqual(self.get_selected_tab_index(), self.selected_tab_index) + self.marionette.navigate(second_page) + + # The restart will cause the background tab to stay unloaded + self.marionette.restart(in_app=True) + self.assertEqual(len(self.marionette.window_handles), 2) + + # Refresh window handles + window_handles = self.marionette.window_handles + self.assertEqual(len(window_handles), 2) + + current_tab = self.marionette.current_window_handle + [other_tab] = filter(lambda handle: handle != current_tab, window_handles) + + Wait(self.marionette, timeout=5).until( + lambda _: self.marionette.get_url() == second_page, + message="Expected URL in the second tab has been loaded", + ) + + self.marionette.switch_to_window(other_tab) + Wait(self.marionette, timeout=5).until( + lambda _: self.marionette.get_url() == first_page, + message="Expected URL in the first tab has been loaded", + ) + + def test_switch_from_content_to_chrome_window_should_not_change_selected_tab(self): + new_tab = self.open_tab(focus=True) + + self.marionette.switch_to_window(new_tab) + self.assertEqual(self.marionette.current_window_handle, new_tab) + new_tab_index = self.get_selected_tab_index() + + self.marionette.switch_to_window(self.start_window) + self.assertEqual(self.marionette.current_window_handle, new_tab) + self.assertEqual(self.get_selected_tab_index(), new_tab_index) + + def test_switch_to_new_private_browsing_tab(self): + # Test that tabs (browsers) are correctly registered for a newly opened + # private browsing window/tab. This has to also happen without explicitely + # switching to the tab itself before using any commands in content scope. + # + # Note: Not sure why this only affects private browsing windows only. + new_tab = self.open_tab(focus=True) + self.marionette.switch_to_window(new_tab) + + def open_private_browsing_window_firefox(): + with self.marionette.using_context("content"): + self.marionette.find_element(By.ID, "startPrivateBrowsing").click() + + def open_private_browsing_tab_fennec(): + with self.marionette.using_context("content"): + self.marionette.find_element(By.ID, "newPrivateTabLink").click() + + with self.marionette.using_context("content"): + self.marionette.navigate("about:privatebrowsing") + if self.marionette.session_capabilities["browserName"] == "fennec": + new_pb_tab = self.open_tab(open_private_browsing_tab_fennec) + else: + new_pb_tab = self.open_tab(open_private_browsing_window_firefox) + + self.marionette.switch_to_window(new_pb_tab) + self.assertEqual(self.marionette.current_window_handle, new_pb_tab) + + self.marionette.execute_script(" return true; ") + + def test_switch_to_window_after_remoteness_change(self): + # Test that after a remoteness change (and a browsing context swap) + # marionette can still switch to tabs correctly. + with self.marionette.using_context("content"): + # about:robots runs in a different process and will trigger a + # remoteness change with or without fission. + self.marionette.navigate("about:robots") + + about_robots_tab = self.marionette.current_window_handle + + # Open a new tab and switch to it before trying to switch back to the + # initial tab. + tab2 = self.open_tab(focus=True) + self.marionette.switch_to_window(tab2) + self.marionette.close() + + self.marionette.switch_to_window(about_robots_tab) + self.assertEqual(self.marionette.current_window_handle, about_robots_tab) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_teardown_context_preserved.py b/testing/marionette/harness/marionette_harness/tests/unit/test_teardown_context_preserved.py new file mode 100644 index 0000000000..59653393c8 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_teardown_context_preserved.py @@ -0,0 +1,21 @@ +# 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_harness import MarionetteTestCase, SkipTest + + +class TestTearDownContext(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + self.marionette.set_context(self.marionette.CONTEXT_CHROME) + + def tearDown(self): + self.assertEqual(self.get_context(), self.marionette.CONTEXT_CHROME) + MarionetteTestCase.tearDown(self) + + def get_context(self): + return self.marionette._send_message("Marionette:GetContext", key="value") + + def test_skipped_teardown_ok(self): + raise SkipTest("This should leave our teardown method in chrome context") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_text.py b/testing/marionette/harness/marionette_harness/tests/unit/test_text.py new file mode 100644 index 0000000000..28b7bbe762 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_text.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/. + +from marionette_driver.by import By +from marionette_harness import MarionetteTestCase + + +class TestText(MarionetteTestCase): + def test_get_text(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + l = self.marionette.find_element(By.ID, "mozLink") + self.assertEqual("Click me!", l.text) + + def test_clear_text(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + l = self.marionette.find_element(By.NAME, "myInput") + self.assertEqual( + "asdf", self.marionette.execute_script("return arguments[0].value;", [l]) + ) + l.clear() + self.assertEqual( + "", self.marionette.execute_script("return arguments[0].value;", [l]) + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_text_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_text_chrome.py new file mode 100644 index 0000000000..f72384876d --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_text_chrome.py @@ -0,0 +1,35 @@ +# 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_driver.by import By +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestTextChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestTextChrome, self).setUp() + win = self.open_chrome_window("chrome://remote/content/marionette/test.xhtml") + self.marionette.switch_to_window(win) + + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + + super(TestTextChrome, self).tearDown() + + def test_get_text(self): + elem = self.marionette.find_element(By.ID, "testBox") + self.assertEqual(elem.text, "box") + + def test_clear_text(self): + input = self.marionette.find_element(By.ID, "textInput3") + self.assertEqual( + "test", + self.marionette.execute_script("return arguments[0].value;", [input]), + ) + input.clear() + self.assertEqual( + "", self.marionette.execute_script("return arguments[0].value;", [input]) + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_timeouts.py b/testing/marionette/harness/marionette_harness/tests/unit/test_timeouts.py new file mode 100644 index 0000000000..2a2992cc97 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_timeouts.py @@ -0,0 +1,113 @@ +# 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_driver.by import By +from marionette_driver.errors import ( + MarionetteException, + NoSuchElementException, + ScriptTimeoutException, +) +from marionette_driver.marionette import WebElement + +from marionette_harness import MarionetteTestCase, run_if_manage_instance + + +class TestTimeouts(MarionetteTestCase): + def tearDown(self): + self.marionette.timeout.reset() + MarionetteTestCase.tearDown(self) + + def test_get_timeout_fraction(self): + self.marionette.timeout.script = 0.5 + self.assertEqual(self.marionette.timeout.script, 0.5) + + def test_page_timeout_notdefinetimeout_pass(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + + def test_page_timeout_fail(self): + self.marionette.timeout.page_load = 0 + test_html = self.marionette.absolute_url("slow") + with self.assertRaises(MarionetteException): + self.marionette.navigate(test_html) + + def test_page_timeout_pass(self): + self.marionette.timeout.page_load = 60 + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + + def test_search_timeout_notfound_settimeout(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + self.marionette.timeout.implicit = 1 + with self.assertRaises(NoSuchElementException): + self.marionette.find_element(By.ID, "I'm not on the page") + self.marionette.timeout.implicit = 0 + with self.assertRaises(NoSuchElementException): + self.marionette.find_element(By.ID, "I'm not on the page") + + def test_search_timeout_found_settimeout(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + button = self.marionette.find_element(By.ID, "createDivButton") + button.click() + self.marionette.timeout.implicit = 8 + self.assertEqual( + WebElement, type(self.marionette.find_element(By.ID, "newDiv")) + ) + + def test_search_timeout_found(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + button = self.marionette.find_element(By.ID, "createDivButton") + button.click() + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.ID, "newDiv" + ) + + @run_if_manage_instance("Only runnable if Marionette manages the instance") + def test_reset_timeout(self): + timeouts = [ + getattr(self.marionette.timeout, f) + for f in ( + "implicit", + "page_load", + "script", + ) + ] + + def do_check(callback): + for timeout in timeouts: + timeout = 10000 + self.assertEqual(timeout, 10000) + callback() + for timeout in timeouts: + self.assertNotEqual(timeout, 10000) + + def callback_quit(): + self.marionette.quit() + self.marionette.start_session() + + do_check(self.marionette.restart) + do_check(callback_quit) + + def test_execute_async_timeout_settimeout(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + self.marionette.timeout.script = 1 + with self.assertRaises(ScriptTimeoutException): + self.marionette.execute_async_script("var x = 1;") + + def test_no_timeout_settimeout(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + self.marionette.timeout.script = 1 + self.assertTrue( + self.marionette.execute_async_script( + """ + var callback = arguments[arguments.length - 1]; + setTimeout(function() { callback(true); }, 500); + """ + ) + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_title.py b/testing/marionette/harness/marionette_harness/tests/unit/test_title.py new file mode 100644 index 0000000000..1a81291919 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_title.py @@ -0,0 +1,17 @@ +# 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 six.moves.urllib.parse import quote + +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestTitle(MarionetteTestCase): + def test_basic(self): + self.marionette.navigate(inline("<title>foo</title>")) + self.assertEqual(self.marionette.title, "foo") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_title_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_title_chrome.py new file mode 100644 index 0000000000..31cc3c3cae --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_title_chrome.py @@ -0,0 +1,37 @@ +# 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_harness import MarionetteTestCase, WindowManagerMixin + + +class TestTitleChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestTitleChrome, self).setUp() + + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + + super(TestTitleChrome, self).tearDown() + + def test_get_title_xhtml(self): + win = self.open_chrome_window( + "chrome://remote/content/marionette/test_no_xul.xhtml" + ) + self.marionette.switch_to_window(win) + + expected_title = self.marionette.execute_script("return window.document.title;") + self.assertEqual(self.marionette.title, expected_title) + + def test_get_title_xul(self): + win = self.open_chrome_window("chrome://remote/content/marionette/test.xhtml") + self.marionette.switch_to_window(win) + + expected_title = self.marionette.execute_script( + """ + return window.document.documentElement.getAttribute('title'); + """ + ) + self.assertEqual(self.marionette.title, expected_title) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_transport.py b/testing/marionette/harness/marionette_harness/tests/unit/test_transport.py new file mode 100644 index 0000000000..8b90b9dd03 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_transport.py @@ -0,0 +1,110 @@ +# 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 + +from marionette_driver.transport import Command, Response + +from marionette_harness import MarionetteTestCase + + +get_current_url = ("getCurrentUrl", None) +execute_script = ("executeScript", {"script": "return 42"}) + + +class TestMessageSequencing(MarionetteTestCase): + @property + def last_id(self): + return self.marionette.client.last_id + + @last_id.setter + def last_id(self, new_id): + self.marionette.client.last_id = new_id + + def send(self, name, params): + self.last_id = self.last_id + 1 + cmd = Command(self.last_id, name, params) + self.marionette.client.send(cmd) + return self.last_id + + +class MessageTestCase(MarionetteTestCase): + def assert_attr(self, obj, attr): + self.assertTrue( + hasattr(obj, attr), "object does not have attribute {}".format(attr) + ) + + +class TestCommand(MessageTestCase): + def create(self, msgid="msgid", name="name", params="params"): + return Command(msgid, name, params) + + def test_initialise(self): + cmd = self.create() + self.assert_attr(cmd, "id") + self.assert_attr(cmd, "name") + self.assert_attr(cmd, "params") + self.assertEqual("msgid", cmd.id) + self.assertEqual("name", cmd.name) + self.assertEqual("params", cmd.params) + + def test_stringify(self): + cmd = self.create() + string = str(cmd) + self.assertIn("Command", string) + self.assertIn("id=msgid", string) + self.assertIn("name=name", string) + self.assertIn("params=params", string) + + def test_to_msg(self): + cmd = self.create() + msg = json.loads(cmd.to_msg()) + self.assertEqual(msg[0], Command.TYPE) + self.assertEqual(msg[1], "msgid") + self.assertEqual(msg[2], "name") + self.assertEqual(msg[3], "params") + + def test_from_msg(self): + msg = [Command.TYPE, "msgid", "name", "params"] + cmd = Command.from_msg(msg) + self.assertEqual(msg[1], cmd.id) + self.assertEqual(msg[2], cmd.name) + self.assertEqual(msg[3], cmd.params) + + +class TestResponse(MessageTestCase): + def create(self, msgid="msgid", error="error", result="result"): + return Response(msgid, error, result) + + def test_initialise(self): + resp = self.create() + self.assert_attr(resp, "id") + self.assert_attr(resp, "error") + self.assert_attr(resp, "result") + self.assertEqual("msgid", resp.id) + self.assertEqual("error", resp.error) + self.assertEqual("result", resp.result) + + def test_stringify(self): + resp = self.create() + string = str(resp) + self.assertIn("Response", string) + self.assertIn("id=msgid", string) + self.assertIn("error=error", string) + self.assertIn("result=result", string) + + def test_to_msg(self): + resp = self.create() + msg = json.loads(resp.to_msg()) + self.assertEqual(msg[0], Response.TYPE) + self.assertEqual(msg[1], "msgid") + self.assertEqual(msg[2], "error") + self.assertEqual(msg[3], "result") + + def test_from_msg(self): + msg = [Response.TYPE, "msgid", "error", "result"] + resp = Response.from_msg(msg) + self.assertEqual(msg[1], resp.id) + self.assertEqual(msg[2], resp.error) + self.assertEqual(msg[3], resp.result) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_typing.py b/testing/marionette/harness/marionette_harness/tests/unit/test_typing.py new file mode 100644 index 0000000000..0476927975 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_typing.py @@ -0,0 +1,374 @@ +# 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 six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_driver.errors import ElementNotInteractableException +from marionette_driver.keys import Keys + +from marionette_harness import MarionetteTestCase, skip + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TypingTestCase(MarionetteTestCase): + def setUp(self): + super(TypingTestCase, self).setUp() + + if self.marionette.session_capabilities["platformName"] == "mac": + self.mod_key = Keys.META + else: + self.mod_key = Keys.CONTROL + + +class TestTypingChrome(TypingTestCase): + def setUp(self): + super(TestTypingChrome, self).setUp() + self.marionette.set_context("chrome") + + def test_cut_and_paste_shortcuts(self): + with self.marionette.using_context("content"): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + self.assertEqual("", key_reporter.get_property("value")) + key_reporter.send_keys("zyxwvutsr") + self.assertEqual("zyxwvutsr", key_reporter.get_property("value")) + + # select all and cut + key_reporter.send_keys(self.mod_key, "a") + key_reporter.send_keys(self.mod_key, "x") + self.assertEqual("", key_reporter.get_property("value")) + + url_bar = self.marionette.find_element(By.ID, "urlbar-input") + + # Clear contents first + url_bar.send_keys(self.mod_key, "a") + url_bar.send_keys(Keys.BACK_SPACE) + self.assertEqual("", url_bar.get_property("value")) + + url_bar.send_keys(self.mod_key, "v") + self.assertEqual("zyxwvutsr", url_bar.get_property("value")) + + +class TestTypingContent(TypingTestCase): + def test_should_fire_key_press_events(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + key_reporter.send_keys("a") + result = self.marionette.find_element(By.ID, "result") + self.assertTrue("press:" in result.text) + + def test_should_fire_key_down_events(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + key_reporter.send_keys("I") + result = self.marionette.find_element(By.ID, "result") + self.assertTrue("down" in result.text) + + def test_should_fire_key_up_events(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + key_reporter.send_keys("a") + result = self.marionette.find_element(By.ID, "result") + self.assertTrue("up:" in result.text) + + def test_should_type_lowercase_characters(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + key_reporter.send_keys("abc def") + self.assertEqual("abc def", key_reporter.get_property("value")) + + def test_should_type_uppercase_characters(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + key_reporter.send_keys("ABC DEF") + self.assertEqual("ABC DEF", key_reporter.get_property("value")) + + def test_cut_and_paste_shortcuts(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + self.assertEqual("", key_reporter.get_property("value")) + key_reporter.send_keys("zyxwvutsr") + self.assertEqual("zyxwvutsr", key_reporter.get_property("value")) + + # select all and cut + key_reporter.send_keys(self.mod_key, "a") + key_reporter.send_keys(self.mod_key, "x") + self.assertEqual("", key_reporter.get_property("value")) + + key_reporter.send_keys(self.mod_key, "v") + self.assertEqual("zyxwvutsr", key_reporter.get_property("value")) + + def test_should_type_a_quote_characters(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + key_reporter.send_keys('"') + self.assertEqual('"', key_reporter.get_property("value")) + + def test_should_type_an_at_character(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + key_reporter.send_keys("@") + self.assertEqual("@", key_reporter.get_property("value")) + + def test_should_type_a_mix_of_upper_and_lower_case_character(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + key_reporter.send_keys("me@eXample.com") + self.assertEqual("me@eXample.com", key_reporter.get_property("value")) + + def test_arrow_keys_are_not_printable(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + key_reporter.send_keys(Keys.ARROW_LEFT) + self.assertEqual("", key_reporter.get_property("value")) + + def test_will_simulate_a_key_up_when_entering_text_into_input_elements(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + element = self.marionette.find_element(By.ID, "keyUp") + element.send_keys("I like cheese") + result = self.marionette.find_element(By.ID, "result") + self.assertEqual(result.text, "I like cheese") + + def test_will_simulate_a_key_down_when_entering_text_into_input_elements(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + element = self.marionette.find_element(By.ID, "keyDown") + element.send_keys("I like cheese") + result = self.marionette.find_element(By.ID, "result") + # Because the key down gets the result before the input element is + # filled, we're a letter short here + self.assertEqual(result.text, "I like chees") + + def test_will_simulate_a_key_press_when_entering_text_into_input_elements(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + element = self.marionette.find_element(By.ID, "keyPress") + element.send_keys("I like cheese") + result = self.marionette.find_element(By.ID, "result") + # Because the key down gets the result before the input element is + # filled, we're a letter short here + self.assertEqual(result.text, "I like chees") + + def test_will_simulate_a_keyup_when_entering_text_into_textareas(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + element = self.marionette.find_element(By.ID, "keyUpArea") + element.send_keys("I like cheese") + result = self.marionette.find_element(By.ID, "result") + self.assertEqual("I like cheese", result.text) + + def test_will_simulate_a_keydown_when_entering_text_into_textareas(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + element = self.marionette.find_element(By.ID, "keyDownArea") + element.send_keys("I like cheese") + result = self.marionette.find_element(By.ID, "result") + # Because the key down gets the result before the input element is + # filled, we're a letter short here + self.assertEqual(result.text, "I like chees") + + def test_will_simulate_a_keypress_when_entering_text_into_textareas(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + element = self.marionette.find_element(By.ID, "keyPressArea") + element.send_keys("I like cheese") + result = self.marionette.find_element(By.ID, "result") + # Because the key down gets the result before the input element is + # filled, we're a letter short here + self.assertEqual(result.text, "I like chees") + + def test_should_report_key_code_of_arrow_keys_up_down_events(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + result = self.marionette.find_element(By.ID, "result") + element = self.marionette.find_element(By.ID, "keyReporter") + + element.send_keys(Keys.ARROW_DOWN) + + self.assertIn("down: 40", result.text.strip()) + self.assertIn("up: 40", result.text.strip()) + + element.send_keys(Keys.ARROW_UP) + self.assertIn("down: 38", result.text.strip()) + self.assertIn("up: 38", result.text.strip()) + + element.send_keys(Keys.ARROW_LEFT) + self.assertIn("down: 37", result.text.strip()) + self.assertIn("up: 37", result.text.strip()) + + element.send_keys(Keys.ARROW_RIGHT) + self.assertIn("down: 39", result.text.strip()) + self.assertIn("up: 39", result.text.strip()) + + # And leave no rubbish/printable keys in the "keyReporter" + self.assertEqual("", element.get_property("value")) + + @skip("Reenable in Bug 1068728") + def test_numeric_shift_keys(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + result = self.marionette.find_element(By.ID, "result") + element = self.marionette.find_element(By.ID, "keyReporter") + numeric_shifts_etc = '~!@#$%^&*()_+{}:i"<>?|END~' + element.send_keys(numeric_shifts_etc) + self.assertEqual(numeric_shifts_etc, element.get_property("value")) + self.assertIn(" up: 16", result.text.strip()) + + def test_numeric_non_shift_keys(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + element = self.marionette.find_element(By.ID, "keyReporter") + numeric_line_chars_non_shifted = "`1234567890-=[]\\,.'/42" + element.send_keys(numeric_line_chars_non_shifted) + self.assertEqual(numeric_line_chars_non_shifted, element.get_property("value")) + + def test_lowercase_alpha_keys(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + element = self.marionette.find_element(By.ID, "keyReporter") + lower_alphas = "abcdefghijklmnopqrstuvwxyz" + element.send_keys(lower_alphas) + self.assertEqual(lower_alphas, element.get_property("value")) + + @skip("Reenable in Bug 1068735") + def test_uppercase_alpha_keys(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + result = self.marionette.find_element(By.ID, "result") + element = self.marionette.find_element(By.ID, "keyReporter") + upper_alphas = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + element.send_keys(upper_alphas) + self.assertEqual(upper_alphas, element.get_property("value")) + self.assertIn(" up: 16", result.text.strip()) + + @skip("Reenable in Bug 1068726") + def test_all_printable_keys(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + result = self.marionette.find_element(By.ID, "result") + element = self.marionette.find_element(By.ID, "keyReporter") + all_printable = ( + "!\"#$%&'()*+,-./0123456789:<=>?@ " + "ABCDEFGHIJKLMNOPQRSTUVWXYZ [\\]^_`" + "abcdefghijklmnopqrstuvwxyz{|}~" + ) + element.send_keys(all_printable) + + self.assertTrue(all_printable, element.get_property("value")) + self.assertIn(" up: 16", result.text.strip()) + + @skip("Reenable in Bug 1068733") + def test_special_space_keys(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + element = self.marionette.find_element(By.ID, "keyReporter") + element.send_keys("abcd" + Keys.SPACE + "fgh" + Keys.SPACE + "ij") + self.assertEqual("abcd fgh ij", element.get_property("value")) + + def test_should_type_an_integer(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + element = self.marionette.find_element(By.ID, "keyReporter") + element.send_keys(1234) + self.assertEqual("1234", element.get_property("value")) + + def test_should_send_keys_to_elements_without_the_value_attribute(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + # If we don't get an error below we are good + self.marionette.find_element(By.TAG_NAME, "body").send_keys("foo") + + def test_appends_to_input_text(self): + self.marionette.navigate(inline("<input>")) + el = self.marionette.find_element(By.TAG_NAME, "input") + el.send_keys("foo") + el.send_keys("bar") + self.assertEqual("foobar", el.get_property("value")) + + def test_appends_to_textarea(self): + self.marionette.navigate(inline("<textarea></textarea>")) + textarea = self.marionette.find_element(By.TAG_NAME, "textarea") + textarea.send_keys("foo") + textarea.send_keys("bar") + self.assertEqual("foobar", textarea.get_property("value")) + + def test_send_keys_to_type_input(self): + test_html = self.marionette.absolute_url("html5/test_html_inputs.html") + self.marionette.navigate(test_html) + + num_input = self.marionette.find_element(By.ID, "number") + self.assertEqual( + "", self.marionette.execute_script("return arguments[0].value", [num_input]) + ) + num_input.send_keys("1234") + self.assertEqual( + "1234", + self.marionette.execute_script("return arguments[0].value", [num_input]), + ) + + def test_insert_keys(self): + l = self.marionette.find_element(By.ID, "change") + l.send_keys("abde") + self.assertEqual( + "abde", self.marionette.execute_script("return arguments[0].value;", [l]) + ) + + # Set caret position to the middle of the input text. + self.marionette.execute_script( + """var el = arguments[0]; + el.selectionStart = el.selectionEnd = el.value.length / 2;""", + script_args=[l], + ) + + l.send_keys("c") + self.assertEqual( + "abcde", self.marionette.execute_script("return arguments[0].value;", [l]) + ) + + +class TestTypingContentLegacy(TestTypingContent): + def setUp(self): + super(TestTypingContent, self).setUp() + + self.marionette.delete_session() + self.marionette.start_session({"moz:webdriverClick": False}) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_unhandled_prompt_behavior.py b/testing/marionette/harness/marionette_harness/tests/unit/test_unhandled_prompt_behavior.py new file mode 100644 index 0000000000..d68c0a8468 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_unhandled_prompt_behavior.py @@ -0,0 +1,126 @@ +from marionette_driver import errors +from marionette_driver.marionette import Alert +from marionette_driver.wait import Wait +from marionette_harness import MarionetteTestCase, parameterized + + +class TestUnhandledPromptBehavior(MarionetteTestCase): + def setUp(self): + super(TestUnhandledPromptBehavior, self).setUp() + + self.marionette.delete_session() + + def tearDown(self): + # Ensure to close a possible remaining tab modal dialog + try: + alert = self.marionette.switch_to_alert() + alert.dismiss() + + Wait(self.marionette).until(lambda _: not self.alert_present) + except errors.NoAlertPresentException: + pass + + super(TestUnhandledPromptBehavior, self).tearDown() + + @property + def alert_present(self): + try: + Alert(self.marionette).text + return True + except errors.NoAlertPresentException: + return False + + def perform_user_prompt_check( + self, + prompt_type, + text, + expected_result, + expected_close=True, + expected_notify=True, + ): + if prompt_type not in ["alert", "confirm", "prompt"]: + raise TypeError("Invalid dialog type: {}".format(prompt_type)) + + # No need to call resolve() because opening a prompt stops the script + self.marionette.execute_async_script( + """ + window.return_value = null; + window.return_value = window[arguments[0]](arguments[1]); + """, + script_args=(prompt_type, text), + ) + + if expected_notify: + with self.assertRaises(errors.UnexpectedAlertOpen): + self.marionette.title + # Bug 1469752 - WebDriverError misses optional data property + # self.assertEqual(ex.data.text, text) + else: + self.marionette.title + + self.assertEqual(self.alert_present, not expected_close) + + # Close an expected left-over user prompt + if not expected_close: + alert = self.marionette.switch_to_alert() + alert.dismiss() + + else: + prompt_result = self.marionette.execute_script( + "return window.return_value", new_sandbox=False + ) + self.assertEqual(prompt_result, expected_result) + + @parameterized("alert", "alert", None) + @parameterized("confirm", "confirm", True) + @parameterized("prompt", "prompt", "") + def test_accept(self, prompt_type, result): + self.marionette.start_session({"unhandledPromptBehavior": "accept"}) + self.perform_user_prompt_check( + prompt_type, "foo {}".format(prompt_type), result, expected_notify=False + ) + + @parameterized("alert", "alert", None) + @parameterized("confirm", "confirm", True) + @parameterized("prompt", "prompt", "") + def test_accept_and_notify(self, prompt_type, result): + self.marionette.start_session({"unhandledPromptBehavior": "accept and notify"}) + self.perform_user_prompt_check( + prompt_type, "foo {}".format(prompt_type), result + ) + + @parameterized("alert", "alert", None) + @parameterized("confirm", "confirm", False) + @parameterized("prompt", "prompt", None) + def test_dismiss(self, prompt_type, result): + self.marionette.start_session({"unhandledPromptBehavior": "dismiss"}) + self.perform_user_prompt_check( + prompt_type, "foo {}".format(prompt_type), result, expected_notify=False + ) + + @parameterized("alert", "alert", None) + @parameterized("confirm", "confirm", False) + @parameterized("prompt", "prompt", None) + def test_dismiss_and_notify(self, prompt_type, result): + self.marionette.start_session({"unhandledPromptBehavior": "dismiss and notify"}) + self.perform_user_prompt_check( + prompt_type, "foo {}".format(prompt_type), result + ) + + @parameterized("alert", "alert", None) + @parameterized("confirm", "confirm", None) + @parameterized("prompt", "prompt", None) + def test_ignore(self, prompt_type, result): + self.marionette.start_session({"unhandledPromptBehavior": "ignore"}) + self.perform_user_prompt_check( + prompt_type, "foo {}".format(prompt_type), result, expected_close=False + ) + + @parameterized("alert", "alert", None) + @parameterized("confirm", "confirm", False) + @parameterized("prompt", "prompt", None) + def test_default(self, prompt_type, result): + self.marionette.start_session({}) + self.perform_user_prompt_check( + prompt_type, "foo {}".format(prompt_type), result + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_visibility.py b/testing/marionette/harness/marionette_harness/tests/unit/test_visibility.py new file mode 100644 index 0000000000..8b4bc28061 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_visibility.py @@ -0,0 +1,175 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from six.moves.urllib.parse import quote + +from marionette_driver.by import By + +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +def element_direction_doc(direction): + return inline( + """ + <meta name="viewport" content="initial-scale=1,width=device-width"> + <style> + .element{{ + position: absolute; + {}: -50px; + background_color: red; + width: 100px; + height: 100px; + }} + </style> + <div class='element'></div>""".format( + direction + ) + ) + + +class TestVisibility(MarionetteTestCase): + def testShouldAllowTheUserToTellIfAnElementIsDisplayedOrNot(self): + test_html = self.marionette.absolute_url("visibility.html") + self.marionette.navigate(test_html) + + self.assertTrue(self.marionette.find_element(By.ID, "displayed").is_displayed()) + self.assertFalse(self.marionette.find_element(By.ID, "none").is_displayed()) + self.assertFalse( + self.marionette.find_element(By.ID, "suppressedParagraph").is_displayed() + ) + self.assertFalse(self.marionette.find_element(By.ID, "hidden").is_displayed()) + + def testVisibilityShouldTakeIntoAccountParentVisibility(self): + test_html = self.marionette.absolute_url("visibility.html") + self.marionette.navigate(test_html) + + childDiv = self.marionette.find_element(By.ID, "hiddenchild") + hiddenLink = self.marionette.find_element(By.ID, "hiddenlink") + + self.assertFalse(childDiv.is_displayed()) + self.assertFalse(hiddenLink.is_displayed()) + + def testShouldCountElementsAsVisibleIfStylePropertyHasBeenSet(self): + test_html = self.marionette.absolute_url("visibility.html") + self.marionette.navigate(test_html) + shown = self.marionette.find_element(By.ID, "visibleSubElement") + self.assertTrue(shown.is_displayed()) + + def testShouldModifyTheVisibilityOfAnElementDynamically(self): + test_html = self.marionette.absolute_url("visibility.html") + self.marionette.navigate(test_html) + element = self.marionette.find_element(By.ID, "hideMe") + self.assertTrue(element.is_displayed()) + element.click() + self.assertFalse(element.is_displayed()) + + def testHiddenInputElementsAreNeverVisible(self): + test_html = self.marionette.absolute_url("visibility.html") + self.marionette.navigate(test_html) + + shown = self.marionette.find_element(By.NAME, "hidden") + + self.assertFalse(shown.is_displayed()) + + def test_elements_not_displayed_with_negative_transform(self): + self.marionette.navigate( + inline( + """ + <div id="y" style="transform: translateY(-200%);">hidden</div> + <div id="x" style="transform: translateX(-200%);">hidden</div> + """ + ) + ) + + element_x = self.marionette.find_element(By.ID, "x") + self.assertFalse(element_x.is_displayed()) + element_y = self.marionette.find_element(By.ID, "y") + self.assertFalse(element_y.is_displayed()) + + def test_elements_not_displayed_with_parents_having_negative_transform(self): + self.marionette.navigate( + inline( + """ + <div style="transform: translateY(-200%);"><p id="y">hidden</p></div> + <div style="transform: translateX(-200%);"><p id="x">hidden</p></div> + """ + ) + ) + + element_x = self.marionette.find_element(By.ID, "x") + self.assertFalse(element_x.is_displayed()) + element_y = self.marionette.find_element(By.ID, "y") + self.assertFalse(element_y.is_displayed()) + + def test_element_displayed_with_zero_transform(self): + self.marionette.navigate( + inline( + """ + <div style="transform: translate(0px, 0px);">not hidden</div> + """ + ) + ) + element = self.marionette.find_element(By.TAG_NAME, "div") + self.assertTrue(element.is_displayed()) + + def test_element_displayed_with_negative_transform_but_in_viewport(self): + self.marionette.navigate( + inline( + """ + <div style="margin-top: 1em; transform: translateY(-75%);">not hidden</div> + """ + ) + ) + element = self.marionette.find_element(By.TAG_NAME, "div") + self.assertTrue(element.is_displayed()) + + def testShouldSayElementIsInvisibleWhenOverflowXIsHiddenAndOutOfViewport(self): + test_html = self.marionette.absolute_url("bug814037.html") + self.marionette.navigate(test_html) + overflow_x = self.marionette.find_element(By.ID, "assertMe2") + self.assertFalse(overflow_x.is_displayed()) + + def testShouldShowElementNotVisibleWithHiddenAttribute(self): + self.marionette.navigate( + inline( + """ + <p hidden>foo</p> + """ + ) + ) + singleHidden = self.marionette.find_element(By.TAG_NAME, "p") + self.assertFalse(singleHidden.is_displayed()) + + def testShouldShowElementNotVisibleWhenParentElementHasHiddenAttribute(self): + self.marionette.navigate( + inline( + """ + <div hidden> + <p>foo</p> + </div> + """ + ) + ) + child = self.marionette.find_element(By.TAG_NAME, "p") + self.assertFalse(child.is_displayed()) + + def testShouldClickOnELementPartiallyOffLeft(self): + test_html = self.marionette.navigate(element_direction_doc("left")) + self.marionette.find_element(By.CSS_SELECTOR, ".element").click() + + def testShouldClickOnELementPartiallyOffRight(self): + test_html = self.marionette.navigate(element_direction_doc("right")) + self.marionette.find_element(By.CSS_SELECTOR, ".element").click() + + def testShouldClickOnELementPartiallyOffTop(self): + test_html = self.marionette.navigate(element_direction_doc("top")) + self.marionette.find_element(By.CSS_SELECTOR, ".element").click() + + def testShouldClickOnELementPartiallyOffBottom(self): + test_html = self.marionette.navigate(element_direction_doc("bottom")) + self.marionette.find_element(By.CSS_SELECTOR, ".element").click() diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_wait.py b/testing/marionette/harness/marionette_harness/tests/unit/test_wait.py new file mode 100644 index 0000000000..7a8f73bd27 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_wait.py @@ -0,0 +1,347 @@ +# 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 sys +import time + +import six + +from marionette_driver import errors, wait +from marionette_driver.wait import Wait + +from marionette_harness import MarionetteTestCase + + +class TickingClock(object): + def __init__(self, incr=1): + self.ticks = 0 + self.increment = incr + + def sleep(self, dur=None): + dur = dur if dur is not None else self.increment + self.ticks += dur + + @property + def now(self): + return self.ticks + + +class SequenceClock(object): + def __init__(self, times): + self.times = times + self.i = 0 + + @property + def now(self): + if len(self.times) > self.i: + self.i += 1 + return self.times[self.i - 1] + + def sleep(self, dur): + pass + + +class MockMarionette(object): + def __init__(self): + self.waited = 0 + + def exception(self, e=None, wait=1): + self.wait() + if self.waited == wait: + if e is None: + e = Exception + raise e + + def true(self, wait=1): + self.wait() + if self.waited == wait: + return True + return None + + def false(self, wait=1): + self.wait() + return False + + def none(self, wait=1): + self.wait() + return None + + def value(self, value, wait=1): + self.wait() + if self.waited == wait: + return value + return None + + def wait(self): + self.waited += 1 + + +def at_third_attempt(clock, end): + return clock.now == 2 + + +def now(clock, end): + return True + + +class SystemClockTest(MarionetteTestCase): + def setUp(self): + super(SystemClockTest, self).setUp() + self.clock = wait.SystemClock() + + def test_construction_initializes_time(self): + self.assertEqual(self.clock._time, time) + + def test_sleep(self): + start = time.time() + self.clock.sleep(0.1) + end = time.time() - start + self.assertGreater(end, 0) + + def test_time_now(self): + self.assertIsNotNone(self.clock.now) + + +class FormalWaitTest(MarionetteTestCase): + def setUp(self): + super(FormalWaitTest, self).setUp() + self.m = MockMarionette() + self.m.timeout = 123 + + def test_construction_with_custom_timeout(self): + wt = Wait(self.m, timeout=42) + self.assertEqual(wt.timeout, 42) + + def test_construction_with_custom_interval(self): + wt = Wait(self.m, interval=42) + self.assertEqual(wt.interval, 42) + + def test_construction_with_custom_clock(self): + c = TickingClock(1) + wt = Wait(self.m, clock=c) + self.assertEqual(wt.clock, c) + + def test_construction_with_custom_exception(self): + wt = Wait(self.m, ignored_exceptions=Exception) + self.assertIn(Exception, wt.exceptions) + self.assertEqual(len(wt.exceptions), 1) + + def test_construction_with_custom_exception_list(self): + exc = [Exception, ValueError] + wt = Wait(self.m, ignored_exceptions=exc) + for e in exc: + self.assertIn(e, wt.exceptions) + self.assertEqual(len(wt.exceptions), len(exc)) + + def test_construction_with_custom_exception_tuple(self): + exc = (Exception, ValueError) + wt = Wait(self.m, ignored_exceptions=exc) + for e in exc: + self.assertIn(e, wt.exceptions) + self.assertEqual(len(wt.exceptions), len(exc)) + + def test_duplicate_exceptions(self): + wt = Wait(self.m, ignored_exceptions=[Exception, Exception]) + self.assertIn(Exception, wt.exceptions) + self.assertEqual(len(wt.exceptions), 1) + + def test_default_timeout(self): + self.assertEqual(wait.DEFAULT_TIMEOUT, 5) + + def test_default_interval(self): + self.assertEqual(wait.DEFAULT_INTERVAL, 0.1) + + def test_end_property(self): + wt = Wait(self.m) + self.assertIsNotNone(wt.end) + + def test_marionette_property(self): + wt = Wait(self.m) + self.assertEqual(wt.marionette, self.m) + + def test_clock_property(self): + wt = Wait(self.m) + self.assertIsInstance(wt.clock, wait.SystemClock) + + def test_timeout_uses_default_if_marionette_timeout_is_none(self): + self.m.timeout = None + wt = Wait(self.m) + self.assertEqual(wt.timeout, wait.DEFAULT_TIMEOUT) + + +class PredicatesTest(MarionetteTestCase): + def test_until(self): + c = wait.SystemClock() + self.assertFalse(wait.until_pred(c, six.MAXSIZE)) + self.assertTrue(wait.until_pred(c, 0)) + + +class WaitUntilTest(MarionetteTestCase): + def setUp(self): + super(WaitUntilTest, self).setUp() + + self.m = MockMarionette() + self.clock = TickingClock() + self.wt = Wait(self.m, timeout=10, interval=1, clock=self.clock) + + def test_true(self): + r = self.wt.until(lambda x: x.true()) + self.assertTrue(r) + self.assertEqual(self.clock.ticks, 0) + + def test_true_within_timeout(self): + r = self.wt.until(lambda x: x.true(wait=5)) + self.assertTrue(r) + self.assertEqual(self.clock.ticks, 4) + + def test_timeout(self): + with self.assertRaises(errors.TimeoutException): + r = self.wt.until(lambda x: x.true(wait=15)) + self.assertEqual(self.clock.ticks, 10) + + def test_exception_raises_immediately(self): + with self.assertRaises(TypeError): + self.wt.until(lambda x: x.exception(e=TypeError)) + self.assertEqual(self.clock.ticks, 0) + + def test_ignored_exception(self): + self.wt.exceptions = (TypeError,) + with self.assertRaises(errors.TimeoutException): + self.wt.until(lambda x: x.exception(e=TypeError)) + + def test_ignored_exception_wrapped_in_timeoutexception(self): + self.wt.exceptions = (TypeError,) + + exc = None + try: + self.wt.until(lambda x: x.exception(e=TypeError)) + except Exception as e: + exc = e + + s = str(exc) + self.assertIsNotNone(exc) + self.assertIsInstance(exc, errors.TimeoutException) + self.assertIn(", caused by {0!r}".format(TypeError), s) + self.assertIn("self.wt.until(lambda x: x.exception(e=TypeError))", s) + + def test_ignored_exception_after_timeout_is_not_raised(self): + with self.assertRaises(errors.TimeoutException): + r = self.wt.until(lambda x: x.exception(wait=15)) + self.assertEqual(self.clock.ticks, 10) + + def test_keyboard_interrupt(self): + with self.assertRaises(KeyboardInterrupt): + self.wt.until(lambda x: x.exception(e=KeyboardInterrupt)) + + def test_system_exit(self): + with self.assertRaises(SystemExit): + self.wt.until(lambda x: x.exception(SystemExit)) + + def test_true_condition_returns_immediately(self): + r = self.wt.until(lambda x: x.true()) + self.assertIsInstance(r, bool) + self.assertTrue(r) + self.assertEqual(self.clock.ticks, 0) + + def test_value(self): + r = self.wt.until(lambda x: "foo") + self.assertEqual(r, "foo") + self.assertEqual(self.clock.ticks, 0) + + def test_custom_predicate(self): + r = self.wt.until(lambda x: x.true(wait=2), is_true=at_third_attempt) + self.assertTrue(r) + self.assertEqual(self.clock.ticks, 1) + + def test_custom_predicate_times_out(self): + with self.assertRaises(errors.TimeoutException): + self.wt.until(lambda x: x.true(wait=4), is_true=at_third_attempt) + + self.assertEqual(self.clock.ticks, 2) + + def test_timeout_elapsed_duration(self): + with self.assertRaisesRegexp( + errors.TimeoutException, "Timed out after 2.0 seconds" + ): + self.wt.until(lambda x: x.true(wait=4), is_true=at_third_attempt) + + def test_timeout_elapsed_rounding(self): + wt = Wait(self.m, clock=SequenceClock([1, 0.01, 1]), timeout=0) + with self.assertRaisesRegexp( + errors.TimeoutException, "Timed out after 1.0 seconds" + ): + wt.until(lambda x: x.true(), is_true=now) + + def test_timeout_elapsed_interval_by_delayed_condition_return(self): + def callback(mn): + self.clock.sleep(11) + return mn.false() + + with self.assertRaisesRegexp( + errors.TimeoutException, "Timed out after 11.0 seconds" + ): + self.wt.until(callback) + # With a delayed conditional return > timeout, only 1 iteration is + # possible + self.assertEqual(self.m.waited, 1) + + def test_timeout_with_delayed_condition_return(self): + def callback(mn): + self.clock.sleep(0.5) + return mn.false() + + with self.assertRaisesRegexp( + errors.TimeoutException, "Timed out after 10.0 seconds" + ): + self.wt.until(callback) + # With a delayed conditional return < interval, 10 iterations should be + # possible + self.assertEqual(self.m.waited, 10) + + def test_timeout_interval_shorter_than_delayed_condition_return(self): + def callback(mn): + self.clock.sleep(2) + return mn.false() + + with self.assertRaisesRegexp( + errors.TimeoutException, "Timed out after 10.0 seconds" + ): + self.wt.until(callback) + # With a delayed return of the conditional which takes twice that long than the interval, + # half of the iterations should be possible + self.assertEqual(self.m.waited, 5) + + def test_message(self): + self.wt.exceptions = (TypeError,) + exc = None + try: + self.wt.until(lambda x: x.exception(e=TypeError), message="hooba") + except errors.TimeoutException as e: + exc = e + + result = str(exc) + self.assertIn("seconds with message: hooba, caused by", result) + + def test_no_message(self): + self.wt.exceptions = (TypeError,) + exc = None + try: + self.wt.until(lambda x: x.exception(e=TypeError), message="") + except errors.TimeoutException as e: + exc = e + + result = str(exc) + self.assertIn("seconds, caused by", result) + + def test_message_has_none_as_its_value(self): + self.wt.exceptions = (TypeError,) + exc = None + try: + self.wt.until(False, None, None) + except errors.TimeoutException as e: + exc = e + + result = str(exc) + self.assertNotIn("with message:", result) + self.assertNotIn("secondsNone", result) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_close_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_close_chrome.py new file mode 100644 index 0000000000..6f7bff3b6c --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_close_chrome.py @@ -0,0 +1,73 @@ +# 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_harness import MarionetteTestCase, WindowManagerMixin + + +class TestCloseWindow(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestCloseWindow, self).setUp() + + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + self.close_all_tabs() + + super(TestCloseWindow, self).tearDown() + + def test_close_chrome_window_for_browser_window(self): + new_window = self.open_window() + self.marionette.switch_to_window(new_window) + + self.assertNotIn(new_window, self.marionette.window_handles) + chrome_window_handles = self.marionette.close_chrome_window() + self.assertNotIn(new_window, chrome_window_handles) + self.assertListEqual(self.start_windows, chrome_window_handles) + self.assertNotIn(new_window, self.marionette.window_handles) + + def test_close_chrome_window_for_non_browser_window(self): + win = self.open_chrome_window("chrome://remote/content/marionette/test.xhtml") + self.marionette.switch_to_window(win) + + self.assertIn(win, self.marionette.chrome_window_handles) + self.assertNotIn(win, self.marionette.window_handles) + chrome_window_handles = self.marionette.close_chrome_window() + self.assertNotIn(win, chrome_window_handles) + self.assertListEqual(self.start_windows, chrome_window_handles) + self.assertNotIn(win, self.marionette.chrome_window_handles) + + def test_close_chrome_window_for_last_open_window(self): + self.close_all_windows() + + self.assertListEqual([], self.marionette.close_chrome_window()) + self.assertListEqual([self.start_tab], self.marionette.window_handles) + self.assertListEqual([self.start_window], self.marionette.chrome_window_handles) + self.assertIsNotNone(self.marionette.session) + + def test_close_window_for_browser_tab(self): + new_tab = self.open_tab() + self.marionette.switch_to_window(new_tab) + + window_handles = self.marionette.close() + self.assertNotIn(new_tab, window_handles) + self.assertListEqual(self.start_tabs, window_handles) + + def test_close_window_for_browser_window_with_single_tab(self): + new_window = self.open_window() + self.marionette.switch_to_window(new_window) + + self.assertEqual(len(self.start_tabs) + 1, len(self.marionette.window_handles)) + window_handles = self.marionette.close() + self.assertNotIn(new_window, window_handles) + self.assertListEqual(self.start_tabs, window_handles) + self.assertListEqual(self.start_windows, self.marionette.chrome_window_handles) + + def test_close_window_for_last_open_tab(self): + self.close_all_tabs() + + self.assertListEqual([], self.marionette.close()) + self.assertListEqual([self.start_tab], self.marionette.window_handles) + self.assertListEqual([self.start_window], self.marionette.chrome_window_handles) + self.assertIsNotNone(self.marionette.session) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_close_content.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_close_content.py new file mode 100644 index 0000000000..fe883baf6b --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_close_content.py @@ -0,0 +1,109 @@ +# 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 six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestCloseWindow(WindowManagerMixin, MarionetteTestCase): + def tearDown(self): + self.close_all_windows() + self.close_all_tabs() + + super(TestCloseWindow, self).tearDown() + + def test_close_chrome_window_for_browser_window(self): + with self.marionette.using_context("chrome"): + new_window = self.open_window() + self.marionette.switch_to_window(new_window) + + self.assertIn(new_window, self.marionette.chrome_window_handles) + chrome_window_handles = self.marionette.close_chrome_window() + self.assertNotIn(new_window, chrome_window_handles) + self.assertListEqual(self.start_windows, chrome_window_handles) + self.assertNotIn(new_window, self.marionette.window_handles) + + def test_close_chrome_window_for_non_browser_window(self): + new_window = self.open_chrome_window( + "chrome://remote/content/marionette/test.xhtml" + ) + self.marionette.switch_to_window(new_window) + + self.assertIn(new_window, self.marionette.chrome_window_handles) + self.assertNotIn(new_window, self.marionette.window_handles) + chrome_window_handles = self.marionette.close_chrome_window() + self.assertNotIn(new_window, chrome_window_handles) + self.assertListEqual(self.start_windows, chrome_window_handles) + self.assertNotIn(new_window, self.marionette.window_handles) + + def test_close_chrome_window_for_last_open_window(self): + self.close_all_windows() + + self.assertListEqual([], self.marionette.close_chrome_window()) + self.assertListEqual([self.start_tab], self.marionette.window_handles) + self.assertListEqual([self.start_window], self.marionette.chrome_window_handles) + self.assertIsNotNone(self.marionette.session) + + def test_close_window_for_browser_tab(self): + new_tab = self.open_tab() + self.marionette.switch_to_window(new_tab) + + window_handles = self.marionette.close() + self.assertNotIn(new_tab, window_handles) + self.assertListEqual(self.start_tabs, window_handles) + + def test_close_window_for_browser_window_with_single_tab(self): + new_tab = self.open_window() + self.marionette.switch_to_window(new_tab) + + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1) + window_handles = self.marionette.close() + self.assertNotIn(new_tab, window_handles) + self.assertListEqual(self.start_tabs, window_handles) + self.assertListEqual(self.start_windows, self.marionette.chrome_window_handles) + + def test_close_window_for_last_open_tab(self): + self.close_all_tabs() + + self.assertListEqual([], self.marionette.close()) + self.assertListEqual([self.start_tab], self.marionette.window_handles) + self.assertListEqual([self.start_window], self.marionette.chrome_window_handles) + self.assertIsNotNone(self.marionette.session) + + def test_close_browserless_tab(self): + self.close_all_tabs() + + test_page = self.marionette.absolute_url("windowHandles.html") + new_tab = self.open_tab() + self.marionette.switch_to_window(new_tab) + self.marionette.navigate(test_page) + self.marionette.switch_to_window(self.start_tab) + + with self.marionette.using_context("chrome"): + self.marionette.execute_async_script( + """ + const { BrowserWindowTracker } = ChromeUtils.importESModule( + "resource:///modules/BrowserWindowTracker.sys.mjs" + ); + + let win = BrowserWindowTracker.getTopWindow(); + win.addEventListener("TabBrowserDiscarded", ev => { + arguments[0](true); + }, { once: true}); + win.gBrowser.discardBrowser(win.gBrowser.tabs[1]); + """ + ) + + window_handles = self.marionette.window_handles + window_handles.remove(self.start_tab) + self.assertEqual(1, len(window_handles)) + self.marionette.switch_to_window(window_handles[0], focus=False) + self.marionette.close() + self.assertListEqual([self.start_tab], self.marionette.window_handles) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_chrome.py new file mode 100644 index 0000000000..f723f82787 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_chrome.py @@ -0,0 +1,253 @@ +# 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 six + +from marionette_driver import errors +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestWindowHandles(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestWindowHandles, self).setUp() + + self.chrome_dialog = "chrome://remote/content/marionette/test_dialog.xhtml" + + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + self.close_all_tabs() + + super(TestWindowHandles, self).tearDown() + + def assert_window_handles(self): + try: + self.assertIsInstance( + self.marionette.current_chrome_window_handle, six.string_types + ) + self.assertIsInstance( + self.marionette.current_window_handle, six.string_types + ) + except errors.NoSuchWindowException: + pass + + for handle in self.marionette.chrome_window_handles: + self.assertIsInstance(handle, six.string_types) + + for handle in self.marionette.window_handles: + self.assertIsInstance(handle, six.string_types) + + def test_chrome_window_handles_with_scopes(self): + new_browser = self.open_window() + self.assert_window_handles() + self.assertEqual( + len(self.marionette.chrome_window_handles), len(self.start_windows) + 1 + ) + self.assertIn(new_browser, self.marionette.chrome_window_handles) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + + new_dialog = self.open_chrome_window(self.chrome_dialog) + self.assert_window_handles() + self.assertEqual( + len(self.marionette.chrome_window_handles), len(self.start_windows) + 2 + ) + self.assertIn(new_dialog, self.marionette.chrome_window_handles) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + + chrome_window_handles_in_chrome_scope = self.marionette.chrome_window_handles + window_handles_in_chrome_scope = self.marionette.window_handles + + with self.marionette.using_context("content"): + self.assertEqual( + self.marionette.chrome_window_handles, + chrome_window_handles_in_chrome_scope, + ) + self.assertEqual( + self.marionette.window_handles, window_handles_in_chrome_scope + ) + + def test_chrome_window_handles_after_opening_new_chrome_window(self): + new_window = self.open_chrome_window(self.chrome_dialog) + self.assert_window_handles() + self.assertEqual( + len(self.marionette.chrome_window_handles), len(self.start_windows) + 1 + ) + self.assertIn(new_window, self.marionette.chrome_window_handles) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + + # Check that the new chrome window has the correct URL loaded + self.marionette.switch_to_window(new_window) + self.assert_window_handles() + self.assertEqual(self.marionette.current_chrome_window_handle, new_window) + self.assertEqual(self.marionette.get_url(), self.chrome_dialog) + + # Close the chrome window, and carry on in our original window. + self.marionette.close_chrome_window() + self.assert_window_handles() + self.assertEqual( + len(self.marionette.chrome_window_handles), len(self.start_windows) + ) + self.assertNotIn(new_window, self.marionette.chrome_window_handles) + + self.marionette.switch_to_window(self.start_window) + self.assert_window_handles() + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + + def test_chrome_window_handles_after_opening_new_window(self): + new_window = self.open_window() + self.assert_window_handles() + self.assertEqual( + len(self.marionette.chrome_window_handles), len(self.start_windows) + 1 + ) + self.assertIn(new_window, self.marionette.chrome_window_handles) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + + self.marionette.switch_to_window(new_window) + self.assert_window_handles() + self.assertEqual(self.marionette.current_chrome_window_handle, new_window) + + # Close the opened window and carry on in our original window. + self.marionette.close() + self.assert_window_handles() + self.assertEqual( + len(self.marionette.chrome_window_handles), len(self.start_windows) + ) + self.assertNotIn(new_window, self.marionette.chrome_window_handles) + + self.marionette.switch_to_window(self.start_window) + self.assert_window_handles() + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + + def test_chrome_window_handles_after_session_created(self): + new_window = self.open_chrome_window(self.chrome_dialog) + self.assert_window_handles() + self.assertEqual( + len(self.marionette.chrome_window_handles), len(self.start_windows) + 1 + ) + self.assertIn(new_window, self.marionette.chrome_window_handles) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + + chrome_window_handles = self.marionette.chrome_window_handles + + self.marionette.delete_session() + self.marionette.start_session() + + self.assert_window_handles() + self.assertEqual(chrome_window_handles, self.marionette.chrome_window_handles) + + self.marionette.switch_to_window(new_window) + + def test_window_handles_after_opening_new_tab(self): + with self.marionette.using_context("content"): + new_tab = self.open_tab() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1) + self.assertIn(new_tab, self.marionette.window_handles) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + self.marionette.switch_to_window(new_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, new_tab) + + self.marionette.switch_to_window(self.start_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + self.marionette.switch_to_window(new_tab) + self.marionette.close() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + self.assertNotIn(new_tab, self.marionette.window_handles) + + self.marionette.switch_to_window(self.start_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + def test_window_handles_after_opening_new_foreground_tab(self): + with self.marionette.using_context("content"): + new_tab = self.open_tab(focus=True) + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1) + self.assertIn(new_tab, self.marionette.window_handles) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + # We still have the default tab set as our window handle. This + # get_url command should be sent immediately, and not be forever-queued. + with self.marionette.using_context("content"): + self.marionette.get_url() + + self.marionette.switch_to_window(new_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, new_tab) + + self.marionette.close() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + self.assertNotIn(new_tab, self.marionette.window_handles) + + self.marionette.switch_to_window(self.start_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + def test_window_handles_after_opening_new_chrome_window(self): + new_window = self.open_chrome_window(self.chrome_dialog) + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + self.assertNotIn(new_window, self.marionette.window_handles) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + self.marionette.switch_to_window(new_window) + self.assert_window_handles() + self.assertEqual(self.marionette.get_url(), self.chrome_dialog) + + # Check that the opened dialog is not accessible via window handles + with self.assertRaises(errors.NoSuchWindowException): + self.marionette.current_window_handle + with self.assertRaises(errors.NoSuchWindowException): + self.marionette.close() + + # Close the dialog and carry on in our original tab. + self.marionette.close_chrome_window() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + + self.marionette.switch_to_window(self.start_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + def test_window_handles_after_closing_original_tab(self): + with self.marionette.using_context("content"): + new_tab = self.open_tab() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1) + self.assertIn(new_tab, self.marionette.window_handles) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + self.marionette.close() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + self.assertIn(new_tab, self.marionette.window_handles) + + self.marionette.switch_to_window(new_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, new_tab) + + def test_window_handles_after_closing_last_window(self): + self.close_all_windows() + self.assertEqual(self.marionette.close_chrome_window(), []) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_content.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_content.py new file mode 100644 index 0000000000..e1c9cb42a0 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_content.py @@ -0,0 +1,156 @@ +# 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 six +from six.moves.urllib.parse import quote + +from marionette_driver import errors +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestWindowHandles(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestWindowHandles, self).setUp() + + self.chrome_dialog = "chrome://remote/content/marionette/test_dialog.xhtml" + + def tearDown(self): + self.close_all_windows() + self.close_all_tabs() + + super(TestWindowHandles, self).tearDown() + + def assert_window_handles(self): + try: + self.assertIsInstance( + self.marionette.current_window_handle, six.string_types + ) + except errors.NoSuchWindowException: + pass + + for handle in self.marionette.window_handles: + self.assertIsInstance(handle, six.string_types) + + def test_window_handles_after_opening_new_tab(self): + new_tab = self.open_tab() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + self.marionette.switch_to_window(new_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, new_tab) + + self.marionette.switch_to_window(self.start_tab) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + self.marionette.switch_to_window(new_tab) + self.marionette.close() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + + self.marionette.switch_to_window(self.start_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + def test_window_handles_after_opening_new_browser_window(self): + new_tab = self.open_window() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + self.marionette.switch_to_window(new_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, new_tab) + + # Close the opened window and carry on in our original tab. + self.marionette.close() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + + self.marionette.switch_to_window(self.start_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + def test_window_handles_after_opening_new_non_browser_window(self): + new_window = self.open_chrome_window(self.chrome_dialog) + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertNotIn(new_window, self.marionette.window_handles) + + self.marionette.switch_to_window(new_window) + self.assert_window_handles() + + # Check that the opened window is not accessible via window handles + with self.assertRaises(errors.NoSuchWindowException): + self.marionette.current_window_handle + with self.assertRaises(errors.NoSuchWindowException): + self.marionette.close() + + # Close the opened window and carry on in our original tab. + self.marionette.close_chrome_window() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + + self.marionette.switch_to_window(self.start_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + def test_window_handles_after_session_created(self): + new_window = self.open_chrome_window(self.chrome_dialog) + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertNotIn(new_window, self.marionette.window_handles) + + window_handles = self.marionette.window_handles + + self.marionette.delete_session() + self.marionette.start_session() + + self.assert_window_handles() + self.assertEqual(window_handles, self.marionette.window_handles) + + self.marionette.switch_to_window(new_window) + + def test_window_handles_include_unloaded_tabs(self): + new_tab = self.open_tab() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + self.marionette.switch_to_window(new_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, new_tab) + + # The restart will cause the background tab to stay unloaded + self.marionette.restart(in_app=True) + + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1) + + def test_window_handles_after_closing_original_tab(self): + new_tab = self.open_tab() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertIn(new_tab, self.marionette.window_handles) + + self.marionette.close() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + self.assertNotIn(self.start_tab, self.marionette.window_handles) + + self.marionette.switch_to_window(new_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, new_tab) + + def test_window_handles_after_closing_last_tab(self): + self.close_all_tabs() + self.assertEqual(self.marionette.close(), []) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_management.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_management.py new file mode 100644 index 0000000000..33b75011b0 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_management.py @@ -0,0 +1,141 @@ +# 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_driver import By +from marionette_driver.errors import NoSuchWindowException + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestNoSuchWindowContent(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestNoSuchWindowContent, self).setUp() + + def tearDown(self): + self.close_all_tabs() + super(TestNoSuchWindowContent, self).tearDown() + + def test_closed_chrome_window(self): + with self.marionette.using_context("chrome"): + new_window = self.open_window() + self.marionette.switch_to_window(new_window) + self.marionette.close_chrome_window() + + # When closing a browser window both handles are not available + for context in ("chrome", "content"): + with self.marionette.using_context(context): + with self.assertRaises(NoSuchWindowException): + self.marionette.current_chrome_window_handle + with self.assertRaises(NoSuchWindowException): + self.marionette.current_window_handle + + self.marionette.switch_to_window(self.start_window) + + with self.assertRaises(NoSuchWindowException): + self.marionette.switch_to_window(new_window) + + def test_closed_chrome_window_while_in_frame(self): + new_window = self.open_chrome_window( + "chrome://remote/content/marionette/test.xhtml" + ) + self.marionette.switch_to_window(new_window) + with self.marionette.using_context("chrome"): + self.marionette.switch_to_frame(0) + self.marionette.close_chrome_window() + + with self.assertRaises(NoSuchWindowException): + self.marionette.current_window_handle + with self.assertRaises(NoSuchWindowException): + self.marionette.current_chrome_window_handle + + self.marionette.switch_to_window(self.start_window) + + with self.assertRaises(NoSuchWindowException): + self.marionette.switch_to_window(new_window) + + def test_closed_tab(self): + new_tab = self.open_tab() + self.marionette.switch_to_window(new_tab) + self.marionette.close() + + # Check that only the content window is not available in both contexts + for context in ("chrome", "content"): + with self.marionette.using_context(context): + with self.assertRaises(NoSuchWindowException): + self.marionette.current_window_handle + self.marionette.current_chrome_window_handle + + self.marionette.switch_to_window(self.start_tab) + + with self.assertRaises(NoSuchWindowException): + self.marionette.switch_to_window(new_tab) + + def test_closed_tab_while_in_frame(self): + new_tab = self.open_tab() + self.marionette.switch_to_window(new_tab) + + with self.marionette.using_context("content"): + self.marionette.navigate(self.marionette.absolute_url("test_iframe.html")) + frame = self.marionette.find_element(By.ID, "test_iframe") + self.marionette.switch_to_frame(frame) + self.marionette.close() + + with self.assertRaises(NoSuchWindowException): + self.marionette.current_window_handle + self.marionette.current_chrome_window_handle + + self.marionette.switch_to_window(self.start_tab) + + with self.assertRaises(NoSuchWindowException): + self.marionette.switch_to_window(new_tab) + + +class TestNoSuchWindowChrome(TestNoSuchWindowContent): + def setUp(self): + super(TestNoSuchWindowChrome, self).setUp() + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + super(TestNoSuchWindowChrome, self).tearDown() + + +class TestSwitchWindow(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestSwitchWindow, self).setUp() + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + super(TestSwitchWindow, self).tearDown() + + def test_switch_window_after_open_and_close(self): + with self.marionette.using_context("chrome"): + new_window = self.open_window() + self.assertEqual( + len(self.marionette.chrome_window_handles), len(self.start_windows) + 1 + ) + self.assertIn(new_window, self.marionette.chrome_window_handles) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + + # switch to the new chrome window and close it + self.marionette.switch_to_window(new_window) + self.assertEqual(self.marionette.current_chrome_window_handle, new_window) + self.assertNotEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + + self.marionette.close_chrome_window() + self.assertEqual( + len(self.marionette.chrome_window_handles), len(self.start_windows) + ) + self.assertNotIn(new_window, self.marionette.chrome_window_handles) + + # switch back to the original chrome window + self.marionette.switch_to_window(self.start_window) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_maximize.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_maximize.py new file mode 100644 index 0000000000..e9c2d8ba38 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_maximize.py @@ -0,0 +1,36 @@ +# 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_harness import MarionetteTestCase + + +class TestWindowMaximize(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + self.max = self.marionette.execute_script( + """ + return { + width: window.screen.availWidth, + height: window.screen.availHeight, + }""", + sandbox=None, + ) + + # ensure window is not maximized + self.marionette.set_window_rect( + width=self.max["width"] - 100, height=self.max["height"] - 100 + ) + actual = self.marionette.window_rect + self.assertNotEqual(actual["width"], self.max["width"]) + self.assertNotEqual(actual["height"], self.max["height"]) + + self.original_size = actual + + def tearDown(self): + self.marionette.set_window_rect( + width=self.original_size["width"], height=self.original_size["height"] + ) + + def test_maximize(self): + self.marionette.maximize_window() diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_rect.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_rect.py new file mode 100644 index 0000000000..284989cf5b --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_rect.py @@ -0,0 +1,315 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from marionette_driver.errors import InvalidArgumentException +from marionette_harness import MarionetteTestCase + + +class TestWindowRect(MarionetteTestCase): + def setUp(self): + super(TestWindowRect, self).setUp() + + self.original_rect = self.marionette.window_rect + + self.max = self.marionette.execute_script( + """ + return { + width: window.screen.availWidth, + height: window.screen.availHeight, + }""", + sandbox=None, + ) + + # WebDriver spec says a resize cannot result in window being + # maximised, an error is returned if that is the case; therefore if + # the window is maximised at the start of this test, returning to + # the original size via set_window_rect size will result in error; + # so reset to original size minus 1 pixel width + start_size = { + "height": self.original_rect["height"], + "width": self.original_rect["width"], + } + if ( + start_size["width"] == self.max["width"] + and start_size["height"] == self.max["height"] + ): + start_size["width"] -= 10 + start_size["height"] -= 10 + self.marionette.set_window_rect( + height=start_size["height"], width=start_size["width"] + ) + + def tearDown(self): + x, y = self.original_rect["x"], self.original_rect["y"] + height, width = self.original_rect["height"], self.original_rect["width"] + + self.marionette.set_window_rect(x=x, y=y, height=height, width=width) + + is_fullscreen = self.marionette.execute_script( + "return document.fullscreenElement;", sandbox=None + ) + if is_fullscreen: + self.marionette.fullscreen() + + super(TestWindowRect, self).tearDown() + + def test_get_types(self): + rect = self.marionette.window_rect + self.assertIn("x", rect) + self.assertIn("y", rect) + self.assertIn("height", rect) + self.assertIn("width", rect) + self.assertIsInstance(rect["x"], int) + self.assertIsInstance(rect["y"], int) + self.assertIsInstance(rect["height"], int) + self.assertIsInstance(rect["width"], int) + + def test_set_types(self): + invalid_rects = ( + ["a", "b", "h", "w"], + [1.2, 3.4, 4.5, 5.6], + [True, False, True, False], + [[], [], [], []], + [{}, {}, {}, {}], + ) + for x, y, h, w in invalid_rects: + print("testing invalid type position ({},{})".format(x, y)) + with self.assertRaises(InvalidArgumentException): + self.marionette.set_window_rect(x=x, y=y, height=h, width=w) + + def test_setting_window_rect_with_nulls_errors(self): + with self.assertRaises(InvalidArgumentException): + self.marionette.set_window_rect(height=None, width=None, x=None, y=None) + + def test_set_position(self): + old_position = self.marionette.window_rect + wanted_position = {"x": old_position["x"] + 10, "y": old_position["y"] + 10} + + new_position = self.marionette.set_window_rect( + x=wanted_position["x"], y=wanted_position["y"] + ) + expected_position = self.marionette.window_rect + + self.assertEqual(new_position["x"], wanted_position["x"]) + self.assertEqual(new_position["y"], wanted_position["y"]) + self.assertEqual(new_position["x"], expected_position["x"]) + self.assertEqual(new_position["y"], expected_position["y"]) + + def test_set_size(self): + old_size = self.marionette.window_rect + wanted_size = { + "height": old_size["height"] - 50, + "width": old_size["width"] - 50, + } + + new_size = self.marionette.set_window_rect( + height=wanted_size["height"], width=wanted_size["width"] + ) + expected_size = self.marionette.window_rect + + self.assertEqual( + new_size["width"], + wanted_size["width"], + "New width is {0} but should be {1}".format( + new_size["width"], wanted_size["width"] + ), + ) + self.assertEqual( + new_size["height"], + wanted_size["height"], + "New height is {0} but should be {1}".format( + new_size["height"], wanted_size["height"] + ), + ) + self.assertEqual( + new_size["width"], + expected_size["width"], + "New width is {0} but should be {1}".format( + new_size["width"], expected_size["width"] + ), + ) + self.assertEqual( + new_size["height"], + expected_size["height"], + "New height is {0} but should be {1}".format( + new_size["height"], expected_size["height"] + ), + ) + + def test_set_position_and_size(self): + old_rect = self.marionette.window_rect + wanted_rect = { + "x": old_rect["x"] + 10, + "y": old_rect["y"] + 10, + "width": old_rect["width"] - 50, + "height": old_rect["height"] - 50, + } + + new_rect = self.marionette.set_window_rect( + x=wanted_rect["x"], + y=wanted_rect["y"], + width=wanted_rect["width"], + height=wanted_rect["height"], + ) + expected_rect = self.marionette.window_rect + + self.assertEqual(new_rect["x"], wanted_rect["x"]) + self.assertEqual(new_rect["y"], wanted_rect["y"]) + self.assertEqual( + new_rect["width"], + wanted_rect["width"], + "New width is {0} but should be {1}".format( + new_rect["width"], wanted_rect["width"] + ), + ) + self.assertEqual( + new_rect["height"], + wanted_rect["height"], + "New height is {0} but should be {1}".format( + new_rect["height"], wanted_rect["height"] + ), + ) + self.assertEqual(new_rect["x"], expected_rect["x"]) + self.assertEqual(new_rect["y"], expected_rect["y"]) + self.assertEqual( + new_rect["width"], + expected_rect["width"], + "New width is {0} but should be {1}".format( + new_rect["width"], expected_rect["width"] + ), + ) + self.assertEqual( + new_rect["height"], + expected_rect["height"], + "New height is {0} but should be {1}".format( + new_rect["height"], expected_rect["height"] + ), + ) + + def test_move_to_current_position(self): + old_position = self.marionette.window_rect + new_position = self.marionette.set_window_rect( + x=old_position["x"], y=old_position["y"] + ) + + self.assertEqual(new_position["x"], old_position["x"]) + self.assertEqual(new_position["y"], old_position["y"]) + + def test_move_to_current_size(self): + old_size = self.marionette.window_rect + new_size = self.marionette.set_window_rect( + height=old_size["height"], width=old_size["width"] + ) + + self.assertEqual(new_size["height"], old_size["height"]) + self.assertEqual(new_size["width"], old_size["width"]) + + def test_move_to_current_position_and_size(self): + old_position_and_size = self.marionette.window_rect + new_position_and_size = self.marionette.set_window_rect( + x=old_position_and_size["x"], + y=old_position_and_size["y"], + height=old_position_and_size["height"], + width=old_position_and_size["width"], + ) + + self.assertEqual(new_position_and_size["x"], old_position_and_size["x"]) + self.assertEqual(new_position_and_size["y"], old_position_and_size["y"]) + self.assertEqual(new_position_and_size["width"], old_position_and_size["width"]) + self.assertEqual( + new_position_and_size["height"], old_position_and_size["height"] + ) + + def test_move_to_negative_coordinates(self): + old_position = self.marionette.window_rect + print("Current position: {}".format(old_position["x"], old_position["y"])) + new_position = self.marionette.set_window_rect(x=-8, y=-8) + print( + "Position after requesting move to negative coordinates: {}, {}".format( + new_position["x"], new_position["y"] + ) + ) + + # Different systems will report more or less than (-8,-8) + # depending on the characteristics of the window manager, since + # the screenX/screenY position measures the chrome boundaries, + # including any WM decorations. + # + # This makes this hard to reliably test across different + # environments. Generally we are happy when calling + # marionette.set_window_position with negative coordinates does + # not throw. + # + # Because we have to cater to an unknown set of environments, + # the following assertions are the most common denominator that + # make this test pass, irregardless of system characteristics. + + os = self.marionette.session_capabilities["platformName"] + + # Regardless of platform, headless always supports being positioned + # off-screen. + if self.marionette.session_capabilities["moz:headless"]: + self.assertEqual(-8, new_position["x"]) + self.assertEqual(-8, new_position["y"]) + + # Certain WMs prohibit windows from being moved off-screen, + # but we don't have this information. It should be safe to + # assume a window can be moved to (0,0) or less. + elif os == "linux": + # certain WMs prohibit windows from being moved off-screen + self.assertLessEqual(new_position["x"], 0) + self.assertLessEqual(new_position["y"], 0) + + # On macOS, windows can only be moved off the screen on the + # horizontal axis. The system menu bar also blocks windows from + # being moved to (0,0). + elif os == "mac": + self.assertEqual(-8, new_position["x"]) + self.assertEqual(23, new_position["y"]) + + # It turns out that Windows is the only platform on which the + # window can be reliably positioned off-screen. + elif os == "windows": + self.assertEqual(-8, new_position["x"]) + self.assertEqual(-8, new_position["y"]) + + def test_resize_larger_than_screen(self): + new_size = self.marionette.set_window_rect( + width=self.max["width"] * 2, height=self.max["height"] * 2 + ) + actual_size = self.marionette.window_rect + + # in X the window size may be greater than the bounds of the screen + self.assertGreaterEqual(new_size["width"], self.max["width"]) + self.assertGreaterEqual(new_size["height"], self.max["height"]) + self.assertEqual(actual_size["width"], new_size["width"]) + self.assertEqual(actual_size["height"], new_size["height"]) + + def test_resize_to_available_screen_size(self): + expected_size = self.marionette.set_window_rect( + width=self.max["width"], height=self.max["height"] + ) + result_size = self.marionette.window_rect + + self.assertGreaterEqual(expected_size["width"], self.max["width"]) + self.assertGreaterEqual(expected_size["height"], self.max["height"]) + self.assertEqual(result_size["width"], expected_size["width"]) + self.assertEqual(result_size["height"], expected_size["height"]) + + def test_resize_while_fullscreen(self): + self.marionette.fullscreen() + expected_size = self.marionette.set_window_rect( + width=self.max["width"] - 100, height=self.max["height"] - 100 + ) + result_size = self.marionette.window_rect + + self.assertTrue( + self.marionette.execute_script( + "return window.fullscreenElement == null", sandbox=None + ) + ) + self.assertEqual(self.max["width"] - 100, expected_size["width"]) + self.assertEqual(self.max["height"] - 100, expected_size["height"]) + self.assertEqual(result_size["width"], expected_size["width"]) + self.assertEqual(result_size["height"], expected_size["height"]) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_status_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_status_chrome.py new file mode 100644 index 0000000000..29eb574187 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_status_chrome.py @@ -0,0 +1,23 @@ +# 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 + +# add this directory to the path +sys.path.append(os.path.dirname(__file__)) + +from test_window_status_content import TestNoSuchWindowContent + + +class TestNoSuchWindowChrome(TestNoSuchWindowContent): + def setUp(self): + super(TestNoSuchWindowChrome, self).setUp() + + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + + super(TestNoSuchWindowChrome, self).tearDown() diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_status_content.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_status_content.py new file mode 100644 index 0000000000..1ce5e239a6 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_status_content.py @@ -0,0 +1,94 @@ +# 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_driver import By +from marionette_driver.errors import NoSuchWindowException + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestNoSuchWindowContent(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestNoSuchWindowContent, self).setUp() + + def tearDown(self): + self.close_all_windows() + super(TestNoSuchWindowContent, self).tearDown() + + def test_closed_chrome_window(self): + with self.marionette.using_context("chrome"): + new_window = self.open_window() + self.marionette.switch_to_window(new_window) + self.marionette.close_chrome_window() + + # When closing a browser window both handles are not available + for context in ("chrome", "content"): + print("Testing handles with context {}".format(context)) + with self.marionette.using_context(context): + with self.assertRaises(NoSuchWindowException): + self.marionette.current_chrome_window_handle + with self.assertRaises(NoSuchWindowException): + self.marionette.current_window_handle + + self.marionette.switch_to_window(self.start_window) + + with self.assertRaises(NoSuchWindowException): + self.marionette.switch_to_window(new_window) + + def test_closed_chrome_window_while_in_frame(self): + new_window = self.open_chrome_window( + "chrome://remote/content/marionette/test.xhtml" + ) + self.marionette.switch_to_window(new_window) + + with self.marionette.using_context("chrome"): + self.marionette.switch_to_frame(0) + self.marionette.close_chrome_window() + + with self.assertRaises(NoSuchWindowException): + self.marionette.current_window_handle + with self.assertRaises(NoSuchWindowException): + self.marionette.current_chrome_window_handle + + self.marionette.switch_to_window(self.start_window) + + with self.assertRaises(NoSuchWindowException): + self.marionette.switch_to_window(new_window) + + def test_closed_tab(self): + new_tab = self.open_tab(focus=True) + self.marionette.switch_to_window(new_tab) + self.marionette.close() + + # Check that only the content window is not available in both contexts + for context in ("chrome", "content"): + with self.marionette.using_context(context): + with self.assertRaises(NoSuchWindowException): + self.marionette.current_window_handle + self.marionette.current_chrome_window_handle + + self.marionette.switch_to_window(self.start_tab) + + with self.assertRaises(NoSuchWindowException): + self.marionette.switch_to_window(new_tab) + + def test_closed_tab_while_in_frame(self): + new_tab = self.open_tab() + self.marionette.switch_to_window(new_tab) + + with self.marionette.using_context("content"): + self.marionette.navigate(self.marionette.absolute_url("test_iframe.html")) + frame = self.marionette.find_element(By.ID, "test_iframe") + self.marionette.switch_to_frame(frame) + + self.marionette.close() + + with self.assertRaises(NoSuchWindowException): + self.marionette.current_window_handle + self.marionette.current_chrome_window_handle + + self.marionette.switch_to_window(self.start_tab) + + with self.assertRaises(NoSuchWindowException): + self.marionette.switch_to_window(new_tab) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_type_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_type_chrome.py new file mode 100644 index 0000000000..8737dd2be9 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_type_chrome.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/. + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestWindowTypeChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestWindowTypeChrome, self).setUp() + + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + + super(TestWindowTypeChrome, self).tearDown() + + def test_get_window_type(self): + win = self.open_chrome_window("chrome://remote/content/marionette/test.xhtml") + self.marionette.switch_to_window(win) + + window_type = self.marionette.execute_script( + "return window.document.documentElement.getAttribute('windowtype');" + ) + self.assertEqual(window_type, self.marionette.get_window_type()) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_windowless.py b/testing/marionette/harness/marionette_harness/tests/unit/test_windowless.py new file mode 100644 index 0000000000..e8e98350f7 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_windowless.py @@ -0,0 +1,60 @@ +# 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_driver import errors, Wait +from marionette_harness import MarionetteTestCase + + +class TestWindowless(MarionetteTestCase): + def setUp(self): + super(TestWindowless, self).setUp() + + self.marionette.delete_session() + self.marionette.start_session({"moz:windowless": True}) + + def tearDown(self): + # Reset the browser and active WebDriver session + self.marionette.restart(in_app=True) + self.marionette.delete_session() + + super(TestWindowless, self).tearDown() + + def wait_for_first_window(self): + wait = Wait( + self.marionette, + ignored_exceptions=errors.NoSuchWindowException, + timeout=5, + ) + return wait.until(lambda _: self.marionette.window_handles) + + def test_last_chrome_window_can_be_closed(self): + with self.marionette.using_context("chrome"): + handles = self.marionette.chrome_window_handles + self.assertGreater(len(handles), 0) + self.marionette.switch_to_window(handles[0]) + self.marionette.close_chrome_window() + self.assertEqual(len(self.marionette.chrome_window_handles), 0) + + def test_last_content_window_can_be_closed(self): + handles = self.marionette.window_handles + self.assertGreater(len(handles), 0) + self.marionette.switch_to_window(handles[0]) + self.marionette.close() + self.assertEqual(len(self.marionette.window_handles), 0) + + def test_no_window_handles_after_silent_restart(self): + # Check that windows are present, but not after a silent restart + handles = self.marionette.window_handles + self.assertGreater(len(handles), 0) + + self.marionette.restart(silent=True) + with self.assertRaises(errors.TimeoutException): + self.wait_for_first_window() + + # After a normal restart a browser window will be opened again + self.marionette.restart(in_app=True) + handles = self.wait_for_first_window() + + self.assertGreater(len(handles), 0) + self.marionette.switch_to_window(handles[0]) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/unit-tests.toml b/testing/marionette/harness/marionette_harness/tests/unit/unit-tests.toml new file mode 100644 index 0000000000..e8675e4897 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/unit-tests.toml @@ -0,0 +1,193 @@ +[DEFAULT] + +["test_accessibility.py"] + +["test_actions_key.py"] + +["test_actions_pointer.py"] + +["test_actions_wheel.py"] + +["test_addons.py"] + +["test_capabilities.py"] + +["test_checkbox.py"] + +["test_checkbox_chrome.py"] + +["test_chrome.py"] + +["test_chrome_action.py"] + +["test_chrome_element_css.py"] + +["test_cli_arguments.py"] +skip-if = ["!manage_instance"] + +["test_click.py"] + +["test_click_chrome.py"] + +["test_click_scrolling.py"] + +["test_context.py"] + +["test_cookies.py"] + +["test_crash.py"] +skip-if = [ + "asan", + "!manage_instance", +] + +["test_data_driven.py"] + +["test_date_time_value.py"] + +["test_element_id.py"] + +["test_element_id_chrome.py"] + +["test_element_rect.py"] + +["test_element_rect_chrome.py"] + +["test_element_state.py"] + +["test_element_state_chrome.py"] + +["test_errors.py"] + +["test_execute_async_script.py"] + +["test_execute_isolate.py"] + +["test_execute_sandboxes.py"] + +["test_execute_script.py"] + +["test_expected.py"] + +["test_expectedfail.py"] +expected = "fail" + +["test_file_upload.py"] +skip-if = ["os == 'win'"] # http://bugs.python.org/issue14574 + +["test_findelement.py"] + +["test_findelement_chrome.py"] + +["test_geckoinstance.py"] + +["test_get_computed_label.py"] + +["test_get_computed_role.py"] + +["test_get_current_url_chrome.py"] + +["test_get_shadow_root.py"] + +["test_implicit_waits.py"] + +["test_localization.py"] + +["test_marionette.py"] + +["test_modal_dialogs.py"] + +["test_navigation.py"] + +["test_pagesource.py"] + +["test_pagesource_chrome.py"] + +["test_position.py"] + +["test_prefs.py"] + +["test_prefs_enforce.py"] +skip-if = ["!manage_instance"] + +["test_profile_management.py"] +skip-if = [ + "!manage_instance", + "debug && (os == 'mac' || os == 'linux')", # Bug 1450355 +] + +["test_proxy.py"] + +["test_quit_restart.py"] +skip-if = ["!manage_instance"] + +["test_reftest.py"] +skip-if = ["os == 'mac'"] # bug 1674411 + +["test_rendered_element.py"] + +["test_report.py"] + +["test_screen_orientation.py"] + +["test_screenshot.py"] + +["test_select.py"] + +["test_sendkeys_menupopup_chrome.py"] + +["test_session.py"] + +["test_skip_setup.py"] + +["test_switch_frame.py"] + +["test_switch_frame_chrome.py"] + +["test_switch_window_chrome.py"] + +["test_switch_window_content.py"] + +["test_teardown_context_preserved.py"] + +["test_text.py"] + +["test_text_chrome.py"] + +["test_timeouts.py"] + +["test_title.py"] + +["test_title_chrome.py"] + +["test_transport.py"] + +["test_typing.py"] + +["test_unhandled_prompt_behavior.py"] + +["test_visibility.py"] + +["test_wait.py"] + +["test_window_close_chrome.py"] + +["test_window_close_content.py"] + +["test_window_handles_chrome.py"] + +["test_window_handles_content.py"] + +["test_window_maximize.py"] + +["test_window_rect.py"] +skip-if = ["os == 'linux' && os_version == '18.04' && !swgl"] # Bug 1709584 + +["test_window_status_chrome.py"] + +["test_window_status_content.py"] + +["test_window_type_chrome.py"] + +["test_windowless.py"] +run-if = ["os == 'mac'"] # only supported on MacOS diff --git a/testing/marionette/harness/marionette_harness/tests/unit/webextension-invalid.xpi b/testing/marionette/harness/marionette_harness/tests/unit/webextension-invalid.xpi Binary files differnew file mode 100644 index 0000000000..bd1177462e --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/webextension-invalid.xpi diff --git a/testing/marionette/harness/marionette_harness/tests/unit/webextension-signed.xpi b/testing/marionette/harness/marionette_harness/tests/unit/webextension-signed.xpi Binary files differnew file mode 100644 index 0000000000..5363911af1 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/webextension-signed.xpi diff --git a/testing/marionette/harness/marionette_harness/tests/unit/webextension-unsigned.xpi b/testing/marionette/harness/marionette_harness/tests/unit/webextension-unsigned.xpi Binary files differnew file mode 100644 index 0000000000..cf0fad63b5 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/webextension-unsigned.xpi diff --git a/testing/marionette/harness/marionette_harness/www/actions_scroll.html b/testing/marionette/harness/marionette_harness/www/actions_scroll.html new file mode 100644 index 0000000000..468a699696 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/actions_scroll.html @@ -0,0 +1,139 @@ +<!doctype html> +<meta charset=utf-8> +<html> + <head> + <title>Test Scroll Actions</title> + <style> + div { + padding: 0; + margin: 0; + } + + #not-scrollable { + margin-bottom: 100px; + width: 100px; + height: 50px; + } + + #not-scrollable-content { + width: 200px; + height: 100px; + background-color: #ccc; + } + + #scrollable { + width: 100px; + height: 100px; + overflow: scroll; + } + + #scrollable-content { + width: 600px; + height: 1000px; + background-color: blue; + } + + #iframe { + width: 100px; + height: 100px; + } + + #event-reporter { + white-space: pre-line; + } + </style> + + <script> + var eventReporter; + var allEvents = { events: [] }; + + function addMessage(message) { + eventReporter.textContent = `${message}\n${eventReporter.textContent}`; + } + + function recordWheelEvent(event) { + allEvents.events.push({ + "type": event.type, + "button": event.button, + "buttons": event.buttons, + "pageX": event.pageX, + "pageY": event.pageY, + "deltaX": event.deltaX, + "deltaY": event.deltaY, + "deltaZ": event.deltaZ, + "deltaMode": event.deltaMode, + "target": event.target.id, + }); + + addMessage( + "type: " + event.type + " " + + "button: " + event.button + ", " + + "buttons: " + event.buttons + ", " + + "pageX: " + event.pageX + ", " + + "pageY: " + event.pageY + ", " + + "deltaX: " + event.deltaX + ", " + + "deltaY: " + event.deltaY + ", " + + "deltaZ: " + event.deltaZ + ", " + + "deltaMode: " + event.deltaMode + ", " + + "target id: " + event.target.id + ); + } + + document.addEventListener("DOMContentLoaded", function () { + eventReporter = document.getElementById("event-reporter"); + + var noScroll = document.getElementById("not-scrollable"); + noScroll.addEventListener("wheel", recordWheelEvent); + + var scrollable = document.getElementById("scrollable"); + scrollable.addEventListener("wheel", recordWheelEvent); + }); + </script> + </head> + + <body> + <div> + <h2>Scroll Reporter</h2> + <div id="not-scrollable"> + <div id="not-scrollable-content"></div> + </div> + </div> + + <div> + <h2>Overflow Scroll Reporter</h2> + <div id="scrollable"> + <div id="scrollable-content"></div> + </div> + </div> + + <div> + <h2>iframe Scroll Reporter</h2> + <iframe id="iframe" srcdoc=' + <script> + document.scrollingElement.addEventListener("wheel", event => { + window.parent.recordWheelEvent({ + "type": event.type, + "button": event.button, + "buttons": event.buttons, + "pageX": event.pageX, + "pageY": event.pageY, + "deltaX": event.deltaX, + "deltaY": event.deltaY, + "deltaZ": event.deltaZ, + "deltaMode": event.deltaMode, + "target": event.target + }); + }); + </script> + <div id="iframeContent" style="width: 7500px; height: 7500px; background-color:blue"> + </div>'> + </iframe> + </div> + + <div id="resultContainer"> + <hr /> + <h2>Events</h2> + <div id="event-reporter"></div> + </div> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/addons/webextension-signed.xpi b/testing/marionette/harness/marionette_harness/www/addons/webextension-signed.xpi Binary files differnew file mode 100644 index 0000000000..5363911af1 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/addons/webextension-signed.xpi diff --git a/testing/marionette/harness/marionette_harness/www/addons/webextension-unsigned.xpi b/testing/marionette/harness/marionette_harness/www/addons/webextension-unsigned.xpi Binary files differnew file mode 100644 index 0000000000..cf0fad63b5 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/addons/webextension-unsigned.xpi diff --git a/testing/marionette/harness/marionette_harness/www/black.png b/testing/marionette/harness/marionette_harness/www/black.png Binary files differnew file mode 100644 index 0000000000..b62a3a7bc8 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/black.png diff --git a/testing/marionette/harness/marionette_harness/www/bug814037.html b/testing/marionette/harness/marionette_harness/www/bug814037.html new file mode 100644 index 0000000000..47c2968163 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/bug814037.html @@ -0,0 +1,56 @@ +<html> +<head> +<meta name="viewport" content="minimum-scale=1,width=device-width"> +<style> +body { + width: 100%; + margin: 0px; + transition: transform 300ms ease; + overflow-x: hidden; +} + +body.section1 { + transform: translateX(0%); +} + +body.section2 { + transform: translateX(-100%); +} + +section { + width: 100%; + height: 100%; + position: absolute; +} + +#section1 { + left: 0px; +} + +#section2 { + left: 100%; +} +.mypossie { + position:absolute; + left: -1000px; +} +</style> + +</head> + <body class="section1"> + <section id="section1"> + <div id="assertMe1"> + <p>Section 1</p> + </div> + <button id="b1" onclick="var sect = document.getElementsByTagName('body')[0]; sect.classList.add('section2'); sect.classList.remove('section1');">Show section 2</button> + </section> + + <section id="section2"> + <div id="assertMe2"> + <p>Section 2</p> + </div> + <button id="b2" onclick="var sect = document.getElementsByTagName('body')[0]; sect.classList.add('section1'); sect.classList.remove('section2'); ">Show section 1</button> + </section> + <section class='mypossie'>out in left field!</section> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/click_out_of_bounds_overflow.html b/testing/marionette/harness/marionette_harness/www/click_out_of_bounds_overflow.html new file mode 100644 index 0000000000..f0bee9b469 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/click_out_of_bounds_overflow.html @@ -0,0 +1,90 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<!DOCTYPE html> +<html><head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> +<body> +<div style="height: 100px; overflow: auto;"> + <table> + <tbody> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td><a href="#clicked" id="link">click me</a></td></tr> + </tbody> + </table> +</div> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/clicks.html b/testing/marionette/harness/marionette_harness/www/clicks.html new file mode 100644 index 0000000000..96e9f55171 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/clicks.html @@ -0,0 +1,57 @@ +<html> +<head> + <!-- 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/. --> + <title>Testing Clicks</title> + + <script> + function addMousedownListener() { + let el = document.getElementById('showbutton'); + + el.addEventListener('mousedown', function (evt) { + evt.target.innerText = evt.button; + }); + } + </script> +</head> + +<body> +<h1>Testing Clicks</h1> + +<div> + <p id="links">Links:</p> + <ul> + <li><a href="test.html">333333</a> + <li><a href="test.html" id="normal">Normal</a> + <li><a href="#" id="anchor">I go to an anchor</a> + <li><a href="addons/webextension-unsigned.xpi" id="install-addon">Install Add-on</a> + </ul> +</div> + +<div> + <p id="js-links">Javascript links:</p> + <ul> + <li>Navigate in history: + <a href="javascript:history.back();" id="history-back">Back</a> + <a href="javascript:history.forward();" id="history-forward">Forward</a> + <li><a href="javascript:window.open('test.html', '_blank')" id="new-window">Open a window</a> + <li><a href="javascript:window.close();" id="close-window">Close tab/window</a> + <li><a id="addbuttonlistener" href="javascript:addMousedownListener();">Click</a> to + add an event listener for: <span style="color: red;" id="showbutton">button click</span> + </ul> +</div> + +<div> + <p id="special">Special:</p> + <select id="option" onclick="window.location = '/slow?delay=1'"> + <option>Click to navigate</option> + </select> + + <p style="background-color: rgb(0, 255, 0); width: 5em;"> + <a id="overflowLink" href="test.html">looooooooooong short looooooooooong</a> + </p> +</div> + +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/dom/cache/basicCacheAPI_PBM.html b/testing/marionette/harness/marionette_harness/www/dom/cache/basicCacheAPI_PBM.html new file mode 100644 index 0000000000..8a23acf437 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/dom/cache/basicCacheAPI_PBM.html @@ -0,0 +1,21 @@ +<html> + <head> + <script> + async function ensureCache(name) { + if (!window.testCache) { + window.testCache = await caches.open(name); + } + return window.testCache; + }; + + function releaseCache() { + window.testCache = null; + } + + async function addDataIntoCache(name, request, response) { + let cache = await ensureCache(name); + return cache.put(request, response); + }; + </script> + </head> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/dom/cache/cacheUsage.html b/testing/marionette/harness/marionette_harness/www/dom/cache/cacheUsage.html new file mode 100644 index 0000000000..8bb7da8f71 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/dom/cache/cacheUsage.html @@ -0,0 +1,28 @@ +<html> + <head> + <script> + async function getStorageEstimate() { + let r = await navigator.storage.estimate(); + return r.usage; + } + + function openCache(id) { + return caches.open(id); + } + + async function doCacheWork(id, n) { + let c = await openCache(id); + + const body = new Uint32Array(1024); + self.crypto.getRandomValues(body); + + for (let i = 0; i < n; i++) { + await c.put(new Request(`/data-${i}`), new Response(body)) + } + + await caches.delete(id) + return "success"; + } + </script> + </head> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/dom/indexedDB/basicIDB_PBM.html b/testing/marionette/harness/marionette_harness/www/dom/indexedDB/basicIDB_PBM.html new file mode 100644 index 0000000000..90472d64d2 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/dom/indexedDB/basicIDB_PBM.html @@ -0,0 +1,49 @@ +<html> + <head> + <script> + async function ensureIDB(name, ver, store) { + return new Promise((resolve, reject) => { + let createObjectStore = (db, store) => { + db.createObjectStore(store); + }; + + var req = indexedDB.open(name, ver); + req.onerror = reject; + + req.onsuccess = (event) => { + resolve(req.result); + }; + + req.onupgradeneeded = function (event) { + let db = event.target.result; + createObjectStore(db, store); + }; + }); + }; + + async function addDataIntoIDB(idb, store, key, value) { + let db = await ensureIDB(idb, 1, store); + await (new Promise((resolve, reject) => { + var transaction = db.transaction([store], "readwrite"); + var put = transaction.objectStore(store).put(value, key); + put.onerror = reject; + put.onsuccess = resolve; + })); + + closeIDB(db) + }; + + function closeIDB(db) { + db.close(); + } + + function deleteIDB(db) { + return new Promise((resolve, reject) => { + let deleteReq = indexedDB.deleteDatabase(db); + deleteReq.onerror = reject; + deleteReq.onsuccess = resolve; + }); + } + </script> + </head> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/element_outside_viewport.html b/testing/marionette/harness/marionette_harness/www/element_outside_viewport.html new file mode 100644 index 0000000000..69b66b8759 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/element_outside_viewport.html @@ -0,0 +1,41 @@ +<!DOCTYPE html> +<style> + div { + position: absolute; + width: 100px; + height: 100px; + } + .top { background-color: red; } + #top-70 { left: 80px; top: 0; } + #top-50 { left: 190px; top: 20px; } + #top-30 { left: 300px; top: 40px; } + + .right { background-color: black; } + #right-70 { top: 80px; right: -140px;} + #right-50 { top: 190px; right: -120px;} + #right-30 { top: 300px; right: -100px;} + + .bottom { background-color: blue; } + #bottom-70 { right: -50px; bottom: -140px; } + #bottom-50 { right: 60px; bottom: -120px; } + #bottom-30 { right: 170px; bottom: -100px; } + + .left { background-color: green; } + #left-70 { bottom: -50px; left: 0; } + #left-50 { bottom: 60px; left: 20px; } + #left-30 { bottom: 170px; left: 40px; } +</style> +<body onload="window.scrollTo(70, 70);"> + <div id="top-70" class="top"></div> + <div id="top-50" class="top"></div> + <div id="top-30" class="top"></div> + <div id="right-70" class="right"></div> + <div id="right-50" class="right"></div> + <div id="right-30" class="right"></div> + <div id="bottom-70" class="bottom"></div> + <div id="bottom-50" class="bottom"></div> + <div id="bottom-30" class="bottom"></div> + <div id="left-70" class="left"></div> + <div id="left-50" class="left"></div> + <div id="left-30" class="left"></div> +</body> diff --git a/testing/marionette/harness/marionette_harness/www/empty.html b/testing/marionette/harness/marionette_harness/www/empty.html new file mode 100644 index 0000000000..646edf9a72 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/empty.html @@ -0,0 +1,12 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html> +<head> +<title>Marionette Test</title> +</head> +<body> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/formPage.html b/testing/marionette/harness/marionette_harness/www/formPage.html new file mode 100644 index 0000000000..43fde32431 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/formPage.html @@ -0,0 +1,114 @@ +<html> +<head> + <title>We Leave From Here</title> + + <script type="text/javascript"> + function changePage() { + let newLocation = '/common/page/3'; + window.location = newLocation; + } + </script> +</head> +<body> +There should be a form here: + +<form method="get" action="resultPage.html" name="login"> + <input type="email" id="email"/> + <input type="submit" id="submitButton" value="Hello there"/> +</form> + +<form method="get" action="resultPage.html" name="optional" style="display: block"> + Here's a checkbox: + <input type="checkbox" id="checky" name="checky" value="furrfu"/> + <input type="checkbox" id="checkedchecky" name="checkedchecky" checked="checked" /> + <input type="checkbox" id="disabledchecky" disabled="disabled" name="disabledchecky" /> + <input type="checkbox" id="randomly_disabled_checky" disabled="somerandomstring" checked="checked" name="randomlydisabledchecky" /> + <br/> + <select name="selectomatic"> + <option selected="selected" id="non_multi_option" value="one">One</option> + <option value="two">Two</option> + <option value="four">Four</option> + <option value="still learning how to count, apparently">Still learning how to count, apparently</option> + </select> + + <select name="multi" id="multi" multiple="multiple"> + <option selected="selected" value="eggs">Eggs</option> + <option value="ham">Ham</option> + <option selected="selected" value="sausages">Sausages</option> + <option value="onion gravy">Onion gravy</option> + </select> + + <select name="no-select" disabled="disabled"> + <option value="foo">Foo</option> + </select> + + <select name="select_empty_multiple" multiple> + <option id="multi_1" value="select_1">select_1</option> + <option id="multi_2" value="select_2">select_2</option> + <option id="multi_3" value="select_3">select_3</option> + <option id="multi_4" value="select_4">select_4</option> + </select> + + <select name="multi_true" multiple="true"> + <option id="multi_true_1" value="select_1">select_1</option> + <option id="multi_true_2" value="select_2">select_2</option> + </select> + + <select name="multi_false" multiple="false"> + <option id="multi_false_1" value="select_1">select_1</option> + <option id="multi_false_2" value="select_2">select_2</option> + </select> + + <select id="invisi_select" style="opacity:0;"> + <option selected value="apples">Apples</option> + <option value="oranges">Oranges</option> + </select> + + <select name="select-default"> + <option>One</option> + <option>Two</option> + <option>Four</option> + <option>Still learning how to count, apparently</option> + </select> + + <select name="select_with_spaces"> + <option>One</option> + <option> Two </option> + <option> + Four + </option> + <option> + Still learning how to count, + apparently + </option> + </select> + + <select> + <option id="blankOption"></option> + <option id="optionEmptyValueSet" value="">nothing</option> + </select> + + <br/> + + <input type="radio" id="cheese" name="snack" value="cheese"/>Cheese<br/> + <input type="radio" id="peas" name="snack" value="peas"/>Peas<br/> + <input type="radio" id="cheese_and_peas" name="snack" value="cheese and peas" checked/>Cheese and peas<br/> + <input type="radio" id="nothing" name="snack" value="nowt" disabled="disabled"/>Not a sausage<br/> + <input type="radio" id="randomly_disabled_nothing" name="snack" value="funny nowt" disabled="somedisablingstring"/>Not another sausage + + <input type="hidden" name="hidden" value="fromage" /> + + <p id="cheeseLiker">I like cheese</p> + <input type="submit" value="Click!"/> + + <input type="radio" id="lone_disabled_selected_radio" name="not_a_snack" value="cumberland" checked="checked" disabled="disabled" />Cumberland sausage +</form> + +<form method="get" action="formPage.html"> + <p> + <label for="checkbox-with-label" id="label-for-checkbox-with-label">Label</label><input type="checkbox" id="checkbox-with-label" /> + </p> +</form> +<input id="vsearchGadget" name="SearchableText" type="text" size="18" value="" title="Hvad søger du?" accesskey="4" class="inputLabel" /> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/frameset.html b/testing/marionette/harness/marionette_harness/www/frameset.html new file mode 100644 index 0000000000..e91472c952 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/frameset.html @@ -0,0 +1,13 @@ +<html> + <head> + <title>Unique title</title> + </head> +<frameset cols="*, *, *, *, *, *, *"> + <frame name="first" src="page/1"/> + <frame name="second" src="page/2?title=Fish"/> + <frame name="third" src="formPage.html"/> + <frame name="fourth" src="framesetPage2.html"/> + <frame id="fifth" src="xhtmlTest.html"/> + <frame id="sixth" src="test_iframe.html"/> + <frame id="sixth.iframe1" src="page/3"/> +</frameset> diff --git a/testing/marionette/harness/marionette_harness/www/framesetPage2.html b/testing/marionette/harness/marionette_harness/www/framesetPage2.html new file mode 100644 index 0000000000..5190ceb6ce --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/framesetPage2.html @@ -0,0 +1,7 @@ +<html> +<head></head> +<frameset cols="*, *"> + <frame name="child1" src="test.html"/> + <frame name="child2" src="test.html"/> +</frameset> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/html5/blue.jpg b/testing/marionette/harness/marionette_harness/www/html5/blue.jpg Binary files differnew file mode 100644 index 0000000000..8ea27c42fa --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/blue.jpg diff --git a/testing/marionette/harness/marionette_harness/www/html5/boolean_attributes.html b/testing/marionette/harness/marionette_harness/www/html5/boolean_attributes.html new file mode 100644 index 0000000000..431e575aef --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/boolean_attributes.html @@ -0,0 +1,2 @@ +<!DOCTYPE html> +<input id='disabled' disabled> diff --git a/testing/marionette/harness/marionette_harness/www/html5/geolocation.js b/testing/marionette/harness/marionette_harness/www/html5/geolocation.js new file mode 100644 index 0000000000..4fb4a4747b --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/geolocation.js @@ -0,0 +1,29 @@ +/* eslint-disable no-unsanitized/property */ + +function success(position) { + let message = document.getElementById("status"); + message.innerHTML = + "<img src='http://maps.google.com/maps/api/staticmap?center=" + + position.coords.latitude + + "," + + position.coords.longitude + + "&size=300x200&maptype=roadmap&zoom=12&&markers=size:mid|color:red|" + + position.coords.latitude + + "," + + position.coords.longitude + + "&sensor=false' />"; + message.innerHTML += "<p>Longitude: " + position.coords.longitude + "</p>"; + message.innerHTML += "<p>Latitude: " + position.coords.latitude + "</p>"; + message.innerHTML += "<p>Altitude: " + position.coords.altitude + "</p>"; +} + +function error(msg) { + let message = document.getElementById("status"); + message.innerHTML = "Failed to get geolocation."; +} + +if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition(success, error); +} else { + error("Geolocation is not supported."); +} diff --git a/testing/marionette/harness/marionette_harness/www/html5/green.jpg b/testing/marionette/harness/marionette_harness/www/html5/green.jpg Binary files differnew file mode 100644 index 0000000000..6a0d3bea47 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/green.jpg diff --git a/testing/marionette/harness/marionette_harness/www/html5/offline.html b/testing/marionette/harness/marionette_harness/www/html5/offline.html new file mode 100644 index 0000000000..c24178b5f5 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/offline.html @@ -0,0 +1 @@ +<html><head><title>Offline</title></head><body></body></html> diff --git a/testing/marionette/harness/marionette_harness/www/html5/red.jpg b/testing/marionette/harness/marionette_harness/www/html5/red.jpg Binary files differnew file mode 100644 index 0000000000..f296e27195 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/red.jpg diff --git a/testing/marionette/harness/marionette_harness/www/html5/status.html b/testing/marionette/harness/marionette_harness/www/html5/status.html new file mode 100644 index 0000000000..394116a522 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/status.html @@ -0,0 +1 @@ +<html><head><title>Online</title></head><body></body></html> diff --git a/testing/marionette/harness/marionette_harness/www/html5/test.appcache b/testing/marionette/harness/marionette_harness/www/html5/test.appcache new file mode 100644 index 0000000000..3bc4e00257 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/test.appcache @@ -0,0 +1,11 @@ +CACHE MANIFEST + +CACHE: +# Additional items to cache. +yellow.jpg +red.jpg +blue.jpg +green.jpg + +FALLBACK: +status.html offline.html diff --git a/testing/marionette/harness/marionette_harness/www/html5/test_html_inputs.html b/testing/marionette/harness/marionette_harness/www/html5/test_html_inputs.html new file mode 100644 index 0000000000..a170ced1ab --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/test_html_inputs.html @@ -0,0 +1,2 @@ +<!DOCTYPE html> +<input id='number' type=number> diff --git a/testing/marionette/harness/marionette_harness/www/html5/yellow.jpg b/testing/marionette/harness/marionette_harness/www/html5/yellow.jpg Binary files differnew file mode 100644 index 0000000000..7c609b3712 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/yellow.jpg diff --git a/testing/marionette/harness/marionette_harness/www/html5Page.html b/testing/marionette/harness/marionette_harness/www/html5Page.html new file mode 100644 index 0000000000..fbd943d792 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5Page.html @@ -0,0 +1,46 @@ +<html manifest="html5/test.appcache"> +<!-- +Copyright 2011 Software Freedom Conservancy. + +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. +--> + + +<head> +<title>HTML5</title> +</head> +<body> + +<h3>Geolocation Test</h3> +<div id="status">Location unknown</div> +<script language="javascript" type="text/javascript" src="html5/geolocation.js"></script> + +<h3>Application Cache Test</h3> +<div id="images"> + <p>Current network status: <span id="state"></span></p> + <script> + const state = document.getElementById('state') + setInterval(function () { + state.className = navigator.onLine ? 'online' : 'offline'; + // eslint-disable-next-line no-unsanitized/property + state.innerHTML = navigator.onLine ? 'online' : 'offline'; + }, 250); + </script> + <img id="red" src="html5/red.jpg"> + <img id="blue" src="html5/blue.jpg"> + <img id="green" src="html5/green.jpg"> + <img id="yellow" src="html5/yellow.jpg"> +</div> + +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/keyboard.html b/testing/marionette/harness/marionette_harness/www/keyboard.html new file mode 100644 index 0000000000..e711b31e05 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/keyboard.html @@ -0,0 +1,99 @@ +<?xml version="1.0"?> +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> + <!-- 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/. --> + +<head> + <title>Testing Javascript</title> + <meta name="viewport" content="user-scalable=no"> + <script type="text/javascript"> + const seen = {}; + + function updateResult(event) { + document.getElementById('result').innerText = event.target.value; + } + + function displayMessage(message) { + document.getElementById('result').innerText = message; + } + + function appendMessage(message) { + document.getElementById('result').innerText += " " + message + " "; + } + </script> +</head> + +<body> +<h1>Type Stuff</h1> + +<div id="resultContainer"> + Result: <p id="result"></p> +</div> + +<div> + <form action="#"> + <p> + <label>keyDown: <input type="text" id="keyDown" onkeydown="updateResult(event)"/></label> + <label>keyPress: <input type="text" id="keyPress" onkeypress="updateResult(event)"/></label> + <label>keyUp: <input type="text" id="keyUp" onkeyup="updateResult(event)"/></label> + <label>change: <input type="text" id="change" onchange="updateResult(event)"/></label> + </p> + <p> + <label>change: + <input type="checkbox" id="checkbox" value="checkbox thing" onchange="updateResult(event)"/> + </label> + </p> + <p> + <label>keyDown: + <textarea id="keyDownArea" onkeydown="updateResult(event)" rows="2" cols="15"></textarea> + </label> + <label>keyPress: + <textarea id="keyPressArea" onkeypress="updateResult(event)" rows="2" cols="15"></textarea> + </label> + <label>keyUp: + <textarea id="keyUpArea" onkeyup="updateResult(event)" rows="2" cols="15"></textarea> + </label> + </p> + <p> + <select id="selector" onchange="updateResult(event)"> + <option value="foo">Foo</option> + <option value="bar">Bar</option> + </select> + </p> + </form> +</div> + +<div id="formageddon"> + <form action="#"> + Key Up: <input type="text" id="keyUp" onkeyup="javascript:updateContent(this)"/><br/> + Key Down: <input type="text" id="keyDown" onkeydown="javascript:updateContent(this)"/><br/> + Key Press: <input type="text" id="keyPress" onkeypress="javascript:updateContent(this)"/><br/> + Change: <input type="text" id="change" onkeypress="javascript:displayMessage('change')"/><br/> + <textarea id="keyDownArea" onkeydown="javascript:updateContent(this)" rows="2" cols="15"></textarea> + <textarea id="keyPressArea" onkeypress="javascript:updateContent(this)" rows="2" cols="15"></textarea> + <textarea id="keyUpArea" onkeyup="javascript:updateContent(this)" rows="2" cols="15"></textarea> + <select id="selector" onchange="javascript:updateContent(this)"> + <option value="foo">Foo</option> + <option value="bar">Bar</option> + </select> + <input type="checkbox" id="checkbox" value="checkbox thing" onchange="javascript:updateContent(this)"/> + <input id="clickField" type="text" onclick="document.getElementById('clickField').value='Clicked';" value="Hello"/> + <input id="doubleClickField" type="text" onclick="document.getElementById('doubleClickField').value='Clicked';" ondblclick="document.getElementById('doubleClickField').value='DoubleClicked';" oncontextmenu="document.getElementById('doubleClickField').value='ContextClicked'; return false;" value="DoubleHello"/> + <input id="clearMe" value="Something" onchange="displayMessage('Cleared')"/> + <input type="text" id="notDisplayed" style="display: none"> + </form> +</div> + +<div> + <form> + <input type="text" id="keyReporter" size="80" + onkeyup="appendMessage('up: ' + event.keyCode)" + onkeypress="appendMessage('press: ' + event.keyCode)" + onkeydown="displayMessage(''); appendMessage('down: ' + event.keyCode)" /> + <input name="suppress" onkeydown="if (event.preventDefault) event.preventDefault(); event.returnValue = false; return false;" onkeypress="appendMessage('press');"/> + </form> +</div> + +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_columns.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_columns.html new file mode 100644 index 0000000000..bc414cfc45 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_columns.html @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + +<html> + <head> + <meta charset="UTF-8"> + <style> + #columns { + column-count: 2; + -webkit-column-count: 2; + column-rule: 1px solid lightgray; + -webkit-column-rule: 1px solid lightgray; + border: 1px solid lightblue; + width: 450px; + } + </style> + </head> + <body> + <div id="columns"> + <div id="columns-inner" style="border: 1px solid red;" contenteditable="true"> + <p id="before-image-1">Before image 1</p> + <p><img width="100px" height="30px" src="data:image/gif;base64,R0lGODlhAQABAIABAAD/AP///ywAAAAAAQABAAACAkQBADs="></p> + <p>After image 1</p> + <p>Before image 2</p> + <p><img width="100px" height="30px" src="data:image/gif;base64,R0lGODlhAQABAIABAAD/AP///ywAAAAAAQABAAACAkQBADs="></p> + <p>After image 2</p> + </div> + </div> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_cursor.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_cursor.html new file mode 100644 index 0000000000..fdbd6fe7a8 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_cursor.html @@ -0,0 +1,31 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html id="html"> + <head> + <title>Marionette tests for AccessibleCaret in cursor mode</title> + <style> + .block { + width: 10em; + height: 6em; + word-wrap: break-word; + overflow: auto; + } + </style> + </head> + <body> + <div> + <input id="input" value="ABCDEFGHI"> + <input id="input-padding" style="padding: 1em;" value="ABCDEFGHI"> + </div> + <br> + <div> + <textarea name="textarea" id="textarea" rows="4" cols="6">ABCDEFGHI</textarea> + <textarea id="textarea-one-line" rows="3">ABCDEFGHI</textarea> + </div> + <br> + <div class="block" contenteditable="true" id="contenteditable">ABCDEFGHI</div> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_display_none.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_display_none.html new file mode 100644 index 0000000000..766f320011 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_display_none.html @@ -0,0 +1,10 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html id="html" style="display: none"> + <body> + <div id="content">ABC DEF GHI</div> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe.html new file mode 100644 index 0000000000..175d3c3d5c --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe.html @@ -0,0 +1,15 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html id="html"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <title>Marionette tests for AccessibleCaret in selection mode (iframe)</title> + </head> + <body> + <iframe id="frame" src="test_carets_longtext.html" style="width: 10em; height: 8em;"></iframe> + <input id="input" value="ABC DEF GHI"> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll.html new file mode 100644 index 0000000000..5f4b00e5bd --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> + <title>Bug 1657256: Test select word, scroll up, and drag AccessibleCaret.</title> + <style> + :root { + font: 16px/1.25 monospace; + } + </style> + + <iframe id="iframe" src="test_carets_iframe_scroll_inner.html" style="width: 6em; height: 8em;"></iframe> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll_inner.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll_inner.html new file mode 100644 index 0000000000..1087227007 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll_inner.html @@ -0,0 +1,24 @@ +<!DOCTYPE html> +<html> + <style> + :root { + font: 16px/1.25 monospace; + } + </style> + + <body id="bd"> + AAAAAA + BBBBBB + CCCCCC + <span id="content">DDDDDD</span> + <span id="content2">EEEEEE</span> + FFFFFF + GGGGGG + HHHHHH + IIIIII + JJJJJJ + KKKKKK + LLLLLL + MMMMMM + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_key_scroll.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_key_scroll.html new file mode 100644 index 0000000000..a42a342285 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_key_scroll.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html id="html"> + <style> + :root { + font: 16px/1.25 monospace; + } + div { + width: 100px; + height: 5000px; + border: 5px solid blue; + } + </style> + + <div> + <span id="content">AAAAA</span><br> + <span id="content2">BBBBB</span> + </div> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_longtext.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_longtext.html new file mode 100644 index 0000000000..7e2495509b --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_longtext.html @@ -0,0 +1,9 @@ +<html> + <head> + <title>Bug 1094072: Orientation change test for AccessibleCaret positions</title> + </head> + <body id="bd"> + <h3 id="longtext">long long text for orientation change test long long text for orientation change test long long text for orientation change test long long text for orientation change test</h3> + <div contenteditable="true" id="bottomtext">bottom text</div> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_multipleline.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_multipleline.html new file mode 100644 index 0000000000..fbbefbebcb --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_multipleline.html @@ -0,0 +1,18 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html id="html"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <title>Bug 1019441: Marionette tests for AccessibleCaret (multiple lines)</title> + </head> + <body> + <div><textarea id="textarea2" style="width: 10em; height: 6em; overflow: auto;">First Line Second Line Third Line</textarea></div> + <br> + <div style="width: 10em; height: 6em; overflow: auto;" id="contenteditable2" contenteditable="true">First Line<br><br>Second Line<br><br>Third Line</div> + <br> + <div style="width: 10em; height: 6em; overflow: auto;" id="content2">First Line<br><br>Second Line<br><br>Third Line</div> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_multiplerange.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_multiplerange.html new file mode 100644 index 0000000000..9b9bbe9e9f --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_multiplerange.html @@ -0,0 +1,19 @@ +<html> +<style> +h4 { + user-select: none; +} +</style> +<body id=bd> +<h3 id=sel1>user can select this 1</h3> +<h3 id=sel2>user can select this 2</h3> +<h3 id=sel3>user can select this 3</h3> +<h4 id=nonsel1>user cannot select this 1</h4> +<h4 id=nonsel2>user cannot select this 2</h4> +<h3 id=sel4>user can select this 4</h3> +<h3 id=sel5>user can select this 5</h3> +<h4 id=nonsel3>user cannot select this 3</h4> +<h3 id=sel6>user can select this 6</h3> +<h3 id=sel7>user can select this 7</h3> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_selection.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_selection.html new file mode 100644 index 0000000000..9098ed447c --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_selection.html @@ -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/. --> + +<!DOCTYPE html> +<html id="html"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <title>Marionette tests for AccessibleCaret in selection mode</title> + <style> + body { + /* Clicking on a point outside of viewport will trigger + marionette_driver.errors.MoveTargetOutOfBoundsException. Increase the + margin to prevent that. */ + margin: 30px; + } + .block { + width: 10em; + height: 4em; + word-wrap: break-word; + overflow: auto; + } + </style> + </head> + <body> + <div> + <input id="input" value="ABC DEF GHI"> + <input id="input-padding" style="padding: 1em;" value="ABC DEF GHI"> + + <!-- To successfully select 'B's when 'A' is selected, use sufficient + spaces between 'A's and 'B's to avoid the second caret covers 'B's. --> + <input size="16" id="input-size" value="AAAAAAAA BBBBBBBB"> + </div> + <br> + <div> + <textarea id="textarea" rows="4" cols="8">ABC DEF GHI JKL MNO PQR</textarea> + <textarea id="textarea-disabled" rows="4" cols="8" disabled>ABC DEF GHI JKL MNO PQR</textarea> + <textarea id="textarea-one-line" rows="4" cols="12">ABC DEF GHI</textarea> + </div> + <br> + <div><textarea dir="rtl" id="textarea-rtl" rows="8" cols="8">موزيلا فيرفكس موزيلا فيرفكس</textarea></div> + <br> + <div class="block" contenteditable="true" id="contenteditable">ABC DEF GHI</div> + <br> + <div class="block" id="content">ABC DEF GHI</div> + <br> + <div style="user-select: none;" id="non-selectable">Non-selectable</div> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_svg_shapes.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_svg_shapes.html new file mode 100644 index 0000000000..ea3ad4ecf6 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_svg_shapes.html @@ -0,0 +1,12 @@ +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + +<!DOCTYPE html> +<html> + <body> + <svg xmlns="http://www.w3.org/2000/svg" id="svg-element" width="200" height="200"> + <rect id="rect" x="100" y="100" width="20" height="20"></rect> + </svg> + <p id="text">ABC DEF GHI</p> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/navigation_pushstate.html b/testing/marionette/harness/marionette_harness/www/navigation_pushstate.html new file mode 100644 index 0000000000..fbde792d8c --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/navigation_pushstate.html @@ -0,0 +1,20 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html> +<head> + <title>Navigation by manipulating the browser history</title> + <script type="text/javascript"> + function forward() { + let stateObj = { foo: "bar" }; + history.pushState(stateObj, "", "navigation_pushstate_target.html"); + } + </script> +</head> + +<body> + <p>Navigate <a onclick="javascript:forward();" id="forward">forward</a></p> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/navigation_pushstate_target.html b/testing/marionette/harness/marionette_harness/www/navigation_pushstate_target.html new file mode 100644 index 0000000000..153d0a657f --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/navigation_pushstate_target.html @@ -0,0 +1,13 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html> +<head> +</head> + +<body> + <p id="target">Pushstate target</p> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/nestedElements.html b/testing/marionette/harness/marionette_harness/www/nestedElements.html new file mode 100644 index 0000000000..618bf3231b --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/nestedElements.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<a href="1.html">hello world</a> +<a href="1.html">hello world</a><a href="1.html">hello world</a> +<div name="div1"> + <a href="2.html" name="link1">hello world</a> + <a href="2.html" name="link2">hello world</a> +</div> + +<a href="1.html">hello world</a><a href="1.html">hello world</a><a href="1.html">hello world</a> diff --git a/testing/marionette/harness/marionette_harness/www/reftest/mostly-teal-700x700.html b/testing/marionette/harness/marionette_harness/www/reftest/mostly-teal-700x700.html new file mode 100644 index 0000000000..a5aa12d0d2 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/reftest/mostly-teal-700x700.html @@ -0,0 +1,21 @@ +<!doctype html> +<style> + * { + margin: 0; + padding: 0; + } + + html { + background: teal; + } + + div { + position: absolute; + top: 600px; + left: 600px; + height: 100px; + width: 100px; + background: orange; + } +</style> +<div></div> diff --git a/testing/marionette/harness/marionette_harness/www/reftest/teal-700x700.html b/testing/marionette/harness/marionette_harness/www/reftest/teal-700x700.html new file mode 100644 index 0000000000..e441e88e6d --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/reftest/teal-700x700.html @@ -0,0 +1,21 @@ +<!doctype html> +<style> + * { + margin: 0; + padding: 0; + } + + html { + background: teal; + } + + div { + position: absolute; + top: 600px; + left: 600px; + height: 100px; + width: 100px; + background: transparent; + } +</style> +<div></div> diff --git a/testing/marionette/harness/marionette_harness/www/resultPage.html b/testing/marionette/harness/marionette_harness/www/resultPage.html new file mode 100644 index 0000000000..6e2fea9a14 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/resultPage.html @@ -0,0 +1,16 @@ +<!-- 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/. --> + +<html> +<head> + <title>We Arrive Here</title> +</head> +<body> + + +<div> + <input type='text' id='email'/> +</div> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/serviceworker/install_serviceworker.html b/testing/marionette/harness/marionette_harness/www/serviceworker/install_serviceworker.html new file mode 100644 index 0000000000..572a05c2bc --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/serviceworker/install_serviceworker.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> + <head> + <title>Install Service Worker</title> + </head> + <body> + <script> + navigator.serviceWorker.register("serviceworker.js"); + </script> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/serviceworker/serviceworker.js b/testing/marionette/harness/marionette_harness/www/serviceworker/serviceworker.js new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/serviceworker/serviceworker.js diff --git a/testing/marionette/harness/marionette_harness/www/shim.js b/testing/marionette/harness/marionette_harness/www/shim.js new file mode 100644 index 0000000000..9a8e09d6b6 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/shim.js @@ -0,0 +1,297 @@ +/** + * mouse_event_shim.js: generate mouse events from touch events. + * + * This library listens for touch events and generates mousedown, mousemove + * mouseup, and click events to match them. It captures and dicards any + * real mouse events (non-synthetic events with isTrusted true) that are + * send by gecko so that there are not duplicates. + * + * This library does emit mouseover/mouseout and mouseenter/mouseleave + * events. You can turn them off by setting MouseEventShim.trackMouseMoves to + * false. This means that mousemove events will always have the same target + * as the mousedown even that began the series. You can also call + * MouseEventShim.setCapture() from a mousedown event handler to prevent + * mouse tracking until the next mouseup event. + * + * This library does not support multi-touch but should be sufficient + * to do drags based on mousedown/mousemove/mouseup events. + * + * This library does not emit dblclick events or contextmenu events + */ + +"use strict"; + +(function () { + // Make sure we don't run more than once + if (MouseEventShim) { + return; + } + + // Bail if we're not on running on a platform that sends touch + // events. We don't need the shim code for mouse events. + try { + document.createEvent("TouchEvent"); + } catch (e) { + return; + } + + let starttouch; // The Touch object that we started with + let target; // The element the touch is currently over + let emitclick; // Will we be sending a click event after mouseup? + + // Use capturing listeners to discard all mouse events from gecko + window.addEventListener("mousedown", discardEvent, true); + window.addEventListener("mouseup", discardEvent, true); + window.addEventListener("mousemove", discardEvent, true); + window.addEventListener("click", discardEvent, true); + + function discardEvent(e) { + if (e.isTrusted) { + e.stopImmediatePropagation(); // so it goes no further + if (e.type === "click") { + e.preventDefault(); + } // so it doesn't trigger a change event + } + } + + // Listen for touch events that bubble up to the window. + // If other code has called stopPropagation on the touch events + // then we'll never see them. Also, we'll honor the defaultPrevented + // state of the event and will not generate synthetic mouse events + window.addEventListener("touchstart", handleTouchStart); + window.addEventListener("touchmove", handleTouchMove); + window.addEventListener("touchend", handleTouchEnd); + window.addEventListener("touchcancel", handleTouchEnd); // Same as touchend + + function handleTouchStart(e) { + // If we're already handling a touch, ignore this one + if (starttouch) { + return; + } + + // Ignore any event that has already been prevented + if (e.defaultPrevented) { + return; + } + + // Sometimes an unknown gecko bug causes us to get a touchstart event + // for an iframe target that we can't use because it is cross origin. + // Don't start handling a touch in that case + try { + e.changedTouches[0].target.ownerDocument; + } catch (e) { + // Ignore the event if we can't see the properties of the target + return; + } + + // If there is more than one simultaneous touch, ignore all but the first + starttouch = e.changedTouches[0]; + target = starttouch.target; + emitclick = true; + + // Move to the position of the touch + emitEvent("mousemove", target, starttouch); + + // Now send a synthetic mousedown + let result = emitEvent("mousedown", target, starttouch); + + // If the mousedown was prevented, pass that on to the touch event. + // And remember not to send a click event + if (!result) { + e.preventDefault(); + emitclick = false; + } + } + + function handleTouchEnd(e) { + if (!starttouch) { + return; + } + + // End a MouseEventShim.setCapture() call + if (MouseEventShim.capturing) { + MouseEventShim.capturing = false; + MouseEventShim.captureTarget = null; + } + + for (let i = 0; i < e.changedTouches.length; i++) { + let touch = e.changedTouches[i]; + // If the ended touch does not have the same id, skip it + if (touch.identifier !== starttouch.identifier) { + continue; + } + + emitEvent("mouseup", target, touch); + + // If target is still the same element we started and the touch did not + // move more than the threshold and if the user did not prevent + // the mousedown, then send a click event, too. + if (emitclick) { + emitEvent("click", starttouch.target, touch); + } + + starttouch = null; + return; + } + } + + function handleTouchMove(e) { + if (!starttouch) { + return; + } + + for (let i = 0; i < e.changedTouches.length; i++) { + let touch = e.changedTouches[i]; + // If the ended touch does not have the same id, skip it + if (touch.identifier !== starttouch.identifier) { + continue; + } + + // Don't send a mousemove if the touchmove was prevented + if (e.defaultPrevented) { + return; + } + + // See if we've moved too much to emit a click event + let dx = Math.abs(touch.screenX - starttouch.screenX); + let dy = Math.abs(touch.screenY - starttouch.screenY); + if ( + dx > MouseEventShim.dragThresholdX || + dy > MouseEventShim.dragThresholdY + ) { + emitclick = false; + } + + let tracking = + MouseEventShim.trackMouseMoves && !MouseEventShim.capturing; + + let oldtarget; + let newtarget; + if (tracking) { + // If the touch point moves, then the element it is over + // may have changed as well. Note that calling elementFromPoint() + // forces a layout if one is needed. + // XXX: how expensive is it to do this on each touchmove? + // Can we listen for (non-standard) touchleave events instead? + oldtarget = target; + newtarget = document.elementFromPoint(touch.clientX, touch.clientY); + if (newtarget === null) { + // this can happen as the touch is moving off of the screen, e.g. + newtarget = oldtarget; + } + if (newtarget !== oldtarget) { + leave(oldtarget, newtarget, touch); // mouseout, mouseleave + target = newtarget; + } + } else if (MouseEventShim.captureTarget) { + target = MouseEventShim.captureTarget; + } + + emitEvent("mousemove", target, touch); + + if (tracking && newtarget !== oldtarget) { + enter(newtarget, oldtarget, touch); // mouseover, mouseenter + } + } + } + + // Return true if element a contains element b + function contains(a, b) { + return (a.compareDocumentPosition(b) & 16) !== 0; + } + + // A touch has left oldtarget and entered newtarget + // Send out all the events that are required + function leave(oldtarget, newtarget, touch) { + emitEvent("mouseout", oldtarget, touch, newtarget); + + // If the touch has actually left oldtarget (and has not just moved + // into a child of oldtarget) send a mouseleave event. mouseleave + // events don't bubble, so we have to repeat this up the hierarchy. + for (let e = oldtarget; !contains(e, newtarget); e = e.parentNode) { + emitEvent("mouseleave", e, touch, newtarget); + } + } + + // A touch has entered newtarget from oldtarget + // Send out all the events that are required. + function enter(newtarget, oldtarget, touch) { + emitEvent("mouseover", newtarget, touch, oldtarget); + + // Emit non-bubbling mouseenter events if the touch actually entered + // newtarget and wasn't already in some child of it + for (let e = newtarget; !contains(e, oldtarget); e = e.parentNode) { + emitEvent("mouseenter", e, touch, oldtarget); + } + } + + function emitEvent(type, target, touch, relatedTarget) { + let synthetic = document.createEvent("MouseEvents"); + let bubbles = type !== "mouseenter" && type !== "mouseleave"; + let count = + type === "mousedown" || type === "mouseup" || type === "click" ? 1 : 0; + + synthetic.initMouseEvent( + type, + bubbles, // canBubble + true, // cancelable + window, + count, // detail: click count + touch.screenX, + touch.screenY, + touch.clientX, + touch.clientY, + false, // ctrlKey: we don't have one + false, // altKey: we don't have one + false, // shiftKey: we don't have one + false, // metaKey: we don't have one + 0, // we're simulating the left button + relatedTarget || null + ); + + try { + return target.dispatchEvent(synthetic); + } catch (e) { + console.warn("Exception calling dispatchEvent", type, e); + return true; + } + } +})(); + +const MouseEventShim = { + // It is a known gecko bug that synthetic events have timestamps measured + // in microseconds while regular events have timestamps measured in + // milliseconds. This utility function returns a the timestamp converted + // to milliseconds, if necessary. + getEventTimestamp(e) { + if (e.isTrusted) { + // XXX: Are real events always trusted? + return e.timeStamp; + } + return e.timeStamp / 1000; + }, + + // Set this to false if you don't care about mouseover/out events + // and don't want the target of mousemove events to follow the touch + trackMouseMoves: true, + + // Call this function from a mousedown event handler if you want to guarantee + // that the mousemove and mouseup events will go to the same element + // as the mousedown even if they leave the bounds of the element. This is + // like setting trackMouseMoves to false for just one drag. It is a + // substitute for event.target.setCapture(true) + setCapture(target) { + this.capturing = true; // Will be set back to false on mouseup + if (target) { + this.captureTarget = target; + } + }, + + capturing: false, + + // Keep these in sync with ui.dragThresholdX and ui.dragThresholdY prefs. + // If a touch ever moves more than this many pixels from its starting point + // then we will not synthesize a click event when the touch ends. + dragThresholdX: 25, + dragThresholdY: 25, +}; diff --git a/testing/marionette/harness/marionette_harness/www/slow_resource.html b/testing/marionette/harness/marionette_harness/www/slow_resource.html new file mode 100644 index 0000000000..b87d9f4b86 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/slow_resource.html @@ -0,0 +1,13 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html> +<head> +<title>Slow loading resource</title> +</head> +<body> + <img src="/slow?delay=4" id="slow" /> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/test.html b/testing/marionette/harness/marionette_harness/www/test.html new file mode 100644 index 0000000000..20689a6d59 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test.html @@ -0,0 +1,43 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html> +<head> +<title>Marionette Test</title> +<style> +input[type=text], input[type=button] { + appearance: none; +} +</style> +</head> +<body> + <h1 id="testh1">Test Page</h1> + <script type="text/javascript"> + window.ready = true; + function addDelayedElement() { + setTimeout(createDiv, 2000); + function createDiv() { + let newDiv = document.createElement("div"); + newDiv.id = "newDiv"; + let newContent = document.createTextNode("I am a newly created div!"); + newDiv.appendChild(newContent); + document.body.appendChild(newDiv); + } + } + function clicked() { + let link = document.getElementById("mozLink"); + link.innerHTML = "Clicked"; + } + </script> + <a href="#" id="mozLink" class="linkClass" onclick="clicked()">Click me!</a> + <div id="testDiv"> + <a href="#" id="divLink" class="linkClass" onclick="clicked()">Div click me!</a> + <a href="#" id="divLink2" class="linkClass" onclick="clicked()">Div click me!</a> + </div> + <input name="myInput" type="text" value="asdf"/> + <input name="myCheckBox" type="checkbox" /> + <input id="createDivButton" type="button" value="create a div" onclick="addDelayedElement()" /> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/test_accessibility.html b/testing/marionette/harness/marionette_harness/www/test_accessibility.html new file mode 100644 index 0000000000..8cc9fd6493 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test_accessibility.html @@ -0,0 +1,57 @@ +<!-- 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/. --> + +<!DOCTYPE html> + +<html> +<meta charset="UTF-8"> +<head> +<title>Marionette Test</title> +</head> +<body> + <button id="button1">button1</button> + <button id="button2" aria-label="button2"></button> + <span id="button3">I am a bad button with no accessible</span> + <h1 id="button4">I am a bad button that is actually a header</h1> + <h1 id="button5"> + I am a bad button that is actually an actionable header with a listener + </h1> + <button id="button6"></button> + <button id="button7" aria-hidden="true">button7</button> + <div aria-hidden="true"> + <button id="button8">button8</button> + </div> + <button id="button9" style="position:absolute;left:-100px;top:-455px;"> + button9 + </button> + <button id="button10" style="visibility:hidden;"> + button10 + </button> + <span id="no_accessible_but_displayed">I have no accessible object</span> + <button id="button11" disabled>button11</button> + <button id="button12" aria-disabled="true">button12</button> + <span id="no_accessible_but_disabled" disabled>I have no accessible object</span> + <span id="button13" tabindex="0" role="button" aria-label="Span button">Span button</span> + <span id="button14" role="button" aria-label="Span button">Unexplorable Span button</span> + <button id="button15" style="pointer-events:none;">button15</button> + <div style="pointer-events:none;"> + <button id="button16">button16</button> + </div> + <div style="pointer-events:none;"> + <button style="pointer-events:all;" id="button17">button17</button> + </div> + <input id="input1" title="My Input 1" name="myInput1" type="text" value="asdf"/> + <select> + <option id="option1" value="val1">Val1</option> + <option id="option2" value="val2" selected>Val2</option> + </select> + <script> + 'use strict'; + document.getElementById('button5').addEventListener('click', function() { + // A pseudo button that has a listener but is missing button semantics. + return true; + }); + </script> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/test_clearing.html b/testing/marionette/harness/marionette_harness/www/test_clearing.html new file mode 100644 index 0000000000..2aa3c6a21f --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test_clearing.html @@ -0,0 +1,24 @@ +<html> + <!-- 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/. --> + <body> + <input id="writableTextInput" type="text" value="Test"/> + + <input id="readOnlyTextInput" type="text" readonly value="Test"/> + + <input id="textInputnotenabled" type="text" disabled="true" value="Test"/> + + <textarea id="writableTextArea" rows="2" cols="20"> + This is a sample text area which is supposed to be cleared + </textarea> + + <textarea id="textAreaReadOnly" readonly rows="5" cols="20"> + text area which is not supposed to be cleared</textarea> + + <textarea rows="5" id="textAreaNotenabled" disabled="true" cols="20"> + text area which is not supposed to be cleared</textarea> + + <div id="content-editable" contentEditable="true">This is a contentEditable area</div> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/test_dynamic.html b/testing/marionette/harness/marionette_harness/www/test_dynamic.html new file mode 100644 index 0000000000..504e7e74ba --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test_dynamic.html @@ -0,0 +1,38 @@ +<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" + "http://www.w3.org/TR/html4/loose.dtd"> +<html> + <head> + <title></title> + <script type="text/javascript"> + let next = 0; + + function addMore() { + let box = document.createElement('DIV'); + box.id = 'box' + next++; + box.className = 'redbox'; + box.style.width = '150px'; + box.style.height = '150px'; + box.style.backgroundColor = 'red'; + box.style.border = '1px solid black'; + box.style.margin = '5px'; + window.setTimeout(function() { + document.body.appendChild(box); + }, 1000); + } + + function reveal() { + let elem = document.getElementById('revealed'); + window.setTimeout(function() { + elem.style.display = ''; + }, 1000); + } + </script> + </head> + <body> + <input id="adder" type="button" value="Add a box!" onclick="addMore()"/> + + <input id="reveal" type="button" value="Reveal a new input" onclick="reveal();" /> + + <input id="revealed" style="display:none;" /> + </body> + </html> diff --git a/testing/marionette/harness/marionette_harness/www/test_iframe.html b/testing/marionette/harness/marionette_harness/www/test_iframe.html new file mode 100644 index 0000000000..b323ace679 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test_iframe.html @@ -0,0 +1,16 @@ +<!-- 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/. --> + +<!doctype html> +<html> +<head> +<title>Marionette IFrame Test</title> +</head> +<body> + <h1 id="iframe_page_heading">This is the heading</h1> + + <iframe src="test.html" id="test_iframe"></iframe> + <iframe src="test.html" id="test_iframe" name="test_iframe_name"></iframe> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/test_inner_iframe.html b/testing/marionette/harness/marionette_harness/www/test_inner_iframe.html new file mode 100644 index 0000000000..8c9810d0bb --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test_inner_iframe.html @@ -0,0 +1,13 @@ +<!-- 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/. --> + +<!doctype html> +<html> +<head> +<title>Inner Iframe</title> +</head> +<body> + <iframe src="test.html" id="inner_frame"></iframe> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/test_nested_iframe.html b/testing/marionette/harness/marionette_harness/www/test_nested_iframe.html new file mode 100644 index 0000000000..49ac1b0ba5 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test_nested_iframe.html @@ -0,0 +1,13 @@ +<!-- 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/. --> + +<!doctype html> +<html> +<head> +<title>Marionette IFrame Test</title> +</head> +<body> + <iframe src="test_inner_iframe.html" id="test_iframe"></iframe> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/test_oop_1.html b/testing/marionette/harness/marionette_harness/www/test_oop_1.html new file mode 100644 index 0000000000..29add714cd --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test_oop_1.html @@ -0,0 +1,14 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html> +<head> +<title>OOP Test Frame 1</title> +</head> +<body> + <h1 id="testh1">OOP Test Frame 1</h1> + Hello! +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/test_oop_2.html b/testing/marionette/harness/marionette_harness/www/test_oop_2.html new file mode 100644 index 0000000000..6e5a4962fb --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test_oop_2.html @@ -0,0 +1,14 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html> +<head> +<title>OOP Test Frame 2</title> +</head> +<body> + <h1 id="testh1">OOP Test Frame 2</h1> + Hello! +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/test_windows.html b/testing/marionette/harness/marionette_harness/www/test_windows.html new file mode 100644 index 0000000000..f3759990c0 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test_windows.html @@ -0,0 +1,13 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> +<head> + <title>XHTML Test Page</title> +</head> +<body> + <p><a href="resultPage.html" onClick='javascript:window.open("resultPage.html",null, "menubar=0,location=1,resizable=1,scrollbars=1,status=0,width=700,height=375");' name="windowOne">Open new window</a></p> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/update/complete.mar b/testing/marionette/harness/marionette_harness/www/update/complete.mar Binary files differnew file mode 100644 index 0000000000..375fd7bd08 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/update/complete.mar diff --git a/testing/marionette/harness/marionette_harness/www/update/complete.mar.headers b/testing/marionette/harness/marionette_harness/www/update/complete.mar.headers new file mode 100644 index 0000000000..bcf051e2c7 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/update/complete.mar.headers @@ -0,0 +1 @@ +Content-Length: 86612 diff --git a/testing/marionette/harness/marionette_harness/www/visibility.html b/testing/marionette/harness/marionette_harness/www/visibility.html new file mode 100644 index 0000000000..2296f3cd46 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/visibility.html @@ -0,0 +1,51 @@ +<?xml version="1.0"?> +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> + <!-- 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/. --> +<head> + <title>Testing Visibility</title> +</head> + +<body> + +<div> + <span id="hideMe" onclick="this.style.display = 'none';">Click to hide me.</span> +</div> + +<div id="zero" style="width:0;height:0"> + <div> + <img src="map.png"> + </div> +</div> + +<p id="suppressedParagraph" style="display: none">A paragraph suppressed using CSS display=none</p> + +<div> + <p id="displayed">Displayed</p> + + <form action="#"><input type="hidden" name="hidden" /> </form> + + <p id="none" style="display: none;">Display set to none</p> + + <p id="hidden" style="visibility: hidden;">Hidden</p> + + <div id="hiddenparent" style="height: 2em; display: none;"> + <div id="hiddenchild"> + <a href="#" id="hiddenlink">ok</a> + </div> + </div> + + <div style="visibility: hidden;"> + <span> + <input id="unclickable" /> + <input type="checkbox" id="untogglable" checked="checked" />Check box you can't see + </span> + </div> + + <p id="outer" style="visibility: hidden">A <b id="visibleSubElement" style="visibility: visible">sub-element that is explicitly visible</b> using CSS visibility=visible</p> +</div> + +<input type='text' id='notDisplayed' style='display:none'> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/white.png b/testing/marionette/harness/marionette_harness/www/white.png Binary files differnew file mode 100644 index 0000000000..8a68c11548 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/white.png diff --git a/testing/marionette/harness/marionette_harness/www/windowHandles.html b/testing/marionette/harness/marionette_harness/www/windowHandles.html new file mode 100644 index 0000000000..bcd0b08dc3 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/windowHandles.html @@ -0,0 +1,16 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html> +<head> +<title>Marionette New Tab Link</title> +</head> +<body> + <a href="empty.html" id="new-tab" target="_blank">New Tab</a> + <a href="about:blank" id="new-blank-tab" target="_blank">New blank Tab</a> + + <a href="" id="new-window" onClick='javascript:window.open("empty.html", null, "location=1,toolbar=1");'>New Window</a> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/xhtmlTest.html b/testing/marionette/harness/marionette_harness/www/xhtmlTest.html new file mode 100644 index 0000000000..30940c709e --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/xhtmlTest.html @@ -0,0 +1,79 @@ +<?xml version="1.0"?> +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> + <!-- 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/. --> +<head> + <title>XHTML Test Page</title> +</head> +<body> +<div class="navigation"> + <p><a href="resultPage.html" target="result" name="windowOne">Open new window</a></p> + <p><a href="iframes.html" target="_blank" name="windowTwo">Create a new anonymous window</a></p> + <p><a href="test_iframe.html" name="sameWindow">Open page with iframes in same window</a></p> + <p><a href="test.html" target="result" name="windowThree">Open a window with a close button</a></p> +</div> + +<a name="notext"><b></b></a> + +<div class="content"> + <h1 class="header">XHTML Might Be The Future</h1> + + <p>If you'd like to go elsewhere then <a href="resultPage.html">click me</a>.</p> + + <p>Alternatively, <a href="resultPage.html" id="linkId">this goes to the same place</a>.</p> + + <form name="someForm"> + <input id="username" type="text" value="change"/> + </form> + + This link has the same text as another link: <a href="resultPage.html">click me</a>. +</div> + +<div class="extraDiv">Another div starts here.<p/> + <h2 class="nameA nameBnoise nameC">An H2 title</h2> + <p class="nameC">Some more text</p> +</div> + +<div> + <a id="id1" href="#">Foo</a> + <ul id="id2" /> + <span id="id3"/> +</div> + +<div> + <table id="table" ></table> +</div> + +<span id="amazing"> +<div> + <div> + <div> + <span/> + <a>I have width</a> + </div> + </div> +</div> +</span> + +<a name="text" /> +<p id="spaces"> </p> +<p id="empty"></p> +<a href="foo" id="linkWithEqualsSign">Link=equalssign</a> + +<p class=" spaceAround ">Spaced out</p> + +<span id="my_span"> + <div>first_div</div> + <div>second_div</div> + <span>first_span</span> + <span>second_span</span> +</span> + +<div id="parent">I'm a parent + <div id="child">I'm a child</div> +</div> + +<div id="only-exists-on-xhtmltest">Woo woo</div> +</body> +</html> diff --git a/testing/marionette/harness/requirements.txt b/testing/marionette/harness/requirements.txt new file mode 100644 index 0000000000..aa307b196d --- /dev/null +++ b/testing/marionette/harness/requirements.txt @@ -0,0 +1,15 @@ +browsermob-proxy >= 0.8.0 +manifestparser >= 1.1 +marionette-driver >= 3.0.0 +mozcrash >= 2.0 +mozdevice >= 4.0.0,<5 +mozinfo >= 1.0.0 +mozlog >= 6.0 +moznetwork >= 0.27 +mozprocess >= 1.0.0 +mozprofile >= 2.2.0 +mozrunner >= 7.4.0 +moztest >= 0.8 +mozversion >= 2.1.0 +six +wptserve >= 2.0.0 diff --git a/testing/marionette/harness/setup.py b/testing/marionette/harness/setup.py new file mode 100644 index 0000000000..fd43cdb969 --- /dev/null +++ b/testing/marionette/harness/setup.py @@ -0,0 +1,58 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import os +import re + +from setuptools import find_packages, setup + +THIS_DIR = os.path.dirname(os.path.realpath(__name__)) + + +def read(*parts): + with open(os.path.join(THIS_DIR, *parts)) as f: + return f.read() + + +def get_version(): + return re.findall( + '__version__ = "([\d\.]+)"', read("marionette_harness", "__init__.py"), re.M + )[0] + + +setup( + name="marionette-harness", + version=get_version(), + description="Marionette test automation harness", + long_description=open("README.rst").read(), + # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Topic :: Software Development :: Quality Assurance", + "Topic :: Software Development :: Testing", + "Topic :: Utilities", + "Programming Language :: Python", + "Programming Language :: Python :: 2.7", + ], + keywords="mozilla", + author="Auto-tools", + author_email="dev-webdriver@mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Marionette", + license="Mozilla Public License 2.0 (MPL 2.0)", + packages=find_packages(), + # Needed to include package data as specified in MANIFEST.in + include_package_data=True, + install_requires=read("requirements.txt").splitlines(), + zip_safe=False, + entry_points=""" + # -*- Entry points: -*- + [console_scripts] + marionette = marionette_harness.runtests:cli + """, +) diff --git a/testing/marionette/mach_commands.py b/testing/marionette/mach_commands.py new file mode 100644 index 0000000000..7736806d1e --- /dev/null +++ b/testing/marionette/mach_commands.py @@ -0,0 +1,113 @@ +# 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 argparse +import functools +import logging +import os +import sys + +from six import iteritems + +from mach.decorators import ( + Command, +) + +from mozbuild.base import ( + MachCommandConditions as conditions, + BinaryNotFoundException, +) + +SUPPORTED_APPS = ["firefox", "android", "thunderbird"] + + +def create_parser_tests(): + from marionette_harness.runtests import MarionetteArguments + from mozlog.structured import commandline + + parser = MarionetteArguments() + commandline.add_logging_group(parser) + return parser + + +def run_marionette(tests, binary=None, topsrcdir=None, **kwargs): + from mozlog.structured import commandline + + from marionette_harness.runtests import MarionetteTestRunner, MarionetteHarness + + parser = create_parser_tests() + + args = argparse.Namespace(tests=tests) + + args.binary = binary + args.logger = kwargs.pop("log", None) + + for k, v in iteritems(kwargs): + setattr(args, k, v) + + parser.verify_usage(args) + + # Causes Firefox to crash when using non-local connections. + os.environ["MOZ_DISABLE_NONLOCAL_CONNECTIONS"] = "1" + + if not args.logger: + args.logger = commandline.setup_logging( + "Marionette Unit Tests", args, {"mach": sys.stdout} + ) + failed = MarionetteHarness(MarionetteTestRunner, args=vars(args)).run() + if failed > 0: + return 1 + else: + return 0 + + +@Command( + "marionette-test", + category="testing", + description="Remote control protocol to Gecko, used for browser automation.", + conditions=[functools.partial(conditions.is_buildapp_in, apps=SUPPORTED_APPS)], + parser=create_parser_tests, +) +def marionette_test(command_context, tests, **kwargs): + if "test_objects" in kwargs: + tests = [] + for obj in kwargs["test_objects"]: + tests.append(obj["file_relpath"]) + del kwargs["test_objects"] + + if not tests: + if conditions.is_thunderbird(command_context): + tests = [ + os.path.join( + command_context.topsrcdir, + "comm/testing/marionette/unit-tests.ini", + ) + ] + else: + tests = [ + os.path.join( + command_context.topsrcdir, + "testing/marionette/harness/marionette_harness/tests/unit-tests.toml", + ) + ] + + if not kwargs.get("binary") and ( + conditions.is_firefox(command_context) + or conditions.is_thunderbird(command_context) + ): + try: + kwargs["binary"] = command_context.get_binary_path("app") + except BinaryNotFoundException as e: + command_context.log( + logging.ERROR, + "marionette-test", + {"error": str(e)}, + "ERROR: {error}", + ) + command_context.log( + logging.INFO, "marionette-test", {"help": e.help()}, "{help}" + ) + return 1 + + return run_marionette(tests, topsrcdir=command_context.topsrcdir, **kwargs) diff --git a/testing/marionette/mach_test_package_commands.py b/testing/marionette/mach_test_package_commands.py new file mode 100644 index 0000000000..0bb3df8b89 --- /dev/null +++ b/testing/marionette/mach_test_package_commands.py @@ -0,0 +1,66 @@ +# 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 argparse +import os +import sys +from functools import partial + +from mach.decorators import Command + +parser = None + + +def run_marionette(context, **kwargs): + from marionette.runtests import MarionetteHarness, MarionetteTestRunner + from mozlog.structured import commandline + + args = argparse.Namespace(**kwargs) + args.binary = args.binary or context.firefox_bin + + test_root = os.path.join(context.package_root, "marionette", "tests") + if not args.tests: + args.tests = [ + os.path.join( + test_root, + "testing", + "marionette", + "harness", + "marionette_harness", + "tests", + "unit-tests.toml", + ) + ] + + normalize = partial(context.normalize_test_path, test_root) + args.tests = list(map(normalize, args.tests)) + + commandline.add_logging_group(parser) + parser.verify_usage(args) + + args.logger = commandline.setup_logging( + "Marionette Unit Tests", args, {"mach": sys.stdout} + ) + status = MarionetteHarness(MarionetteTestRunner, args=vars(args)).run() + return 1 if status else 0 + + +def setup_marionette_argument_parser(): + from marionette.runner.base import BaseMarionetteArguments + + global parser + parser = BaseMarionetteArguments() + return parser + + +@Command( + "marionette-test", + category="testing", + description="Run a Marionette test (Check UI or the internal JavaScript " + "using marionette).", + parser=setup_marionette_argument_parser, +) +def run_marionette_test(command_context, **kwargs): + command_context.context.activate_mozharness_venv() + return run_marionette(command_context.context, **kwargs) diff --git a/testing/marionette/moz.build b/testing/marionette/moz.build new file mode 100644 index 0000000000..1986d20904 --- /dev/null +++ b/testing/marionette/moz.build @@ -0,0 +1,13 @@ +# 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/. + +MARIONETTE_MANIFESTS += ["harness/marionette_harness/tests/unit/unit-tests.toml"] + +with Files("**"): + BUG_COMPONENT = ("Testing", "Marionette Client and Harness") + +with Files("harness/**"): + SCHEDULES.exclusive = ["marionette", "firefox-ui"] + +SPHINX_PYTHON_PACKAGE_DIRS += ["client/marionette_driver"] |