diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /testing/marionette/puppeteer/firefox/firefox_puppeteer | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'testing/marionette/puppeteer/firefox/firefox_puppeteer')
33 files changed, 4057 insertions, 0 deletions
diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/__init__.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/__init__.py new file mode 100644 index 000000000..bc6605102 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/__init__.py @@ -0,0 +1,9 @@ +# 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 firefox_puppeteer.mixins import PuppeteerMixin +from firefox_puppeteer.puppeteer import Puppeteer + + +__version__ = '52.1.0' diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/__init__.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/__init__.py diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/appinfo.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/appinfo.py new file mode 100644 index 000000000..13d32c15b --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/appinfo.py @@ -0,0 +1,45 @@ +# 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 firefox_puppeteer.base import BaseLib + + +class AppInfo(BaseLib): + """This class provides access to various attributes of AppInfo. + + For more details on AppInfo, visit: + https://developer.mozilla.org/en-US/docs/Mozilla/QA/Mozmill_tests/Shared_Modules/UtilsAPI/appInfo + """ + + def __getattr__(self, attr): + with self.marionette.using_context('chrome'): + value = self.marionette.execute_script(""" + Components.utils.import("resource://gre/modules/Services.jsm"); + + return Services.appinfo[arguments[0]]; + """, script_args=[attr]) + + if value is not None: + return value + else: + raise AttributeError('{} has no attribute {}'.format(self.__class__.__name__, + attr)) + + @property + def locale(self): + with self.marionette.using_context('chrome'): + return self.marionette.execute_script(""" + return Components.classes["@mozilla.org/chrome/chrome-registry;1"] + .getService(Components.interfaces.nsIXULChromeRegistry) + .getSelectedLocale("global"); + """) + + @property + def user_agent(self): + with self.marionette.using_context('chrome'): + return self.marionette.execute_script(""" + return Components.classes["@mozilla.org/network/protocol;1?name=http"] + .getService(Components.interfaces.nsIHttpProtocolHandler) + .userAgent; + """) diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/keys.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/keys.py new file mode 100644 index 000000000..2c5a1e523 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/keys.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/. + +import marionette_driver + + +class Keys(marionette_driver.keys.Keys): + """Proxy to marionette's keys with an "accel" provided for convenience + testing across platforms.""" + + def __init__(self, marionette): + self.isDarwin = marionette.session_capabilities['platformName'] == 'darwin' + + @property + def ACCEL(self): + return self.META if self.isDarwin else self.CONTROL diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/l10n.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/l10n.py new file mode 100644 index 000000000..f7f52918c --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/l10n.py @@ -0,0 +1,125 @@ +# 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/. + +# ----------------- +# DEPRECATED module +# ----------------- +# Replace its use in tests when Firefox 45 ESR support ends with +# marionette_driver.localization.L10n + +import copy + +from marionette_driver.errors import ( + NoSuchElementException, + UnknownCommandException, +) +from marionette_driver.localization import L10n as L10nMarionette + +from firefox_puppeteer.base import BaseLib + + +class L10n(BaseLib): + """An API which allows Marionette to handle localized content. + + .. deprecated:: 52.2.0 + Use the localization module from :py:mod:`marionette_driver` instead. + + 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. + + .. _localization: https://mzl.la/2eUMjyF + """ + + def __init__(self, marionette): + super(L10n, self).__init__(marionette) + + self._l10nMarionette = L10nMarionette(self.marionette) + + def localize_entity(self, dtd_urls, entity_id): + """Returns the localized string for the specified DTD entity id. + + To find the entity all given DTD files will be searched for the id. + + :param dtd_urls: A list of dtd files to search. + :param entity_id: The id to retrieve the value from. + + :returns: The localized string for the requested entity. + + :raises NoSuchElementException: When entity id is not found in dtd_urls. + """ + # Add xhtml11.dtd to prevent missing entity errors with XHTML files + try: + return self._l10nMarionette.localize_entity(dtd_urls, entity_id) + except UnknownCommandException: + dtds = copy.copy(dtd_urls) + dtds.append("resource:///res/dtd/xhtml11.dtd") + + dtd_refs = '' + for index, item in enumerate(dtds): + dtd_id = 'dtd_%s' % index + dtd_refs += '<!ENTITY %% %s SYSTEM "%s">%%%s;' % \ + (dtd_id, item, dtd_id) + + contents = """<?xml version="1.0"?> + <!DOCTYPE elem [%s]> + + <elem id="entity">&%s;</elem>""" % (dtd_refs, entity_id) + + with self.marionette.using_context('chrome'): + value = self.marionette.execute_script(""" + var parser = Components.classes["@mozilla.org/xmlextras/domparser;1"] + .createInstance(Components.interfaces.nsIDOMParser); + var doc = parser.parseFromString(arguments[0], "text/xml"); + var node = doc.querySelector("elem[id='entity']"); + + return node ? node.textContent : null; + """, script_args=[contents]) + + if not value: + raise NoSuchElementException('DTD Entity not found: %s' % entity_id) + + return value + + def localize_property(self, property_urls, property_id): + """Returns the localized string for the specified property id. + + To find the property all given property files will be searched for + the id. + + :param property_urls: A list of property files to search. + :param property_id: The id to retrieve the value from. + + :returns: The localized string for the requested entity. + + :raises NoSuchElementException: When property id is not found in + property_urls. + """ + try: + return self._l10nMarionette.localize_property(property_urls, property_id) + except UnknownCommandException: + with self.marionette.using_context('chrome'): + value = self.marionette.execute_script(""" + let property = null; + let property_id = arguments[1]; + + arguments[0].some(aUrl => { + let bundle = Services.strings.createBundle(aUrl); + + try { + property = bundle.GetStringFromName(property_id); + return true; + } + catch (ex) { } + }); + + return property; + """, script_args=[property_urls, property_id]) + + if not value: + raise NoSuchElementException('Property not found: %s' % property_id) + + return value diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/places.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/places.py new file mode 100644 index 000000000..fadc2c19b --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/places.py @@ -0,0 +1,150 @@ +# 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 collections import namedtuple +from time import sleep + +from marionette_driver.errors import MarionetteException + +from firefox_puppeteer.base import BaseLib + + +class Places(BaseLib): + """Low-level access to several bookmark and history related actions.""" + + BookmarkFolders = namedtuple('bookmark_folders', + ['root', 'menu', 'toolbar', 'unfiled']) + bookmark_folders = BookmarkFolders('root________', 'menu________', + 'toolbar_____', 'unfiled_____') + + # Bookmark related helpers # + + def is_bookmarked(self, url): + """Check if the given URL is bookmarked. + + :param url: The URL to Check + + :returns: True, if the URL is a bookmark + """ + return self.marionette.execute_async_script(""" + Components.utils.import("resource://gre/modules/PlacesUtils.jsm"); + + PlacesUtils.bookmarks.fetch({url: arguments[0]}).then(bm => { + marionetteScriptFinished(bm != null); + }); + """, script_args=[url]) + + def get_folder_ids_for_url(self, url): + """Retrieve the folder ids where the given URL has been bookmarked in. + + :param url: URL of the bookmark + + :returns: List of folder ids + """ + return self.marionette.execute_async_script(""" + Components.utils.import("resource://gre/modules/PlacesUtils.jsm"); + + let folderGuids = [] + + function onResult(bm) { + folderGuids.push(bm.parentGuid); + } + + PlacesUtils.bookmarks.fetch({url: arguments[0]}, onResult).then(() => { + marionetteScriptFinished(folderGuids); + }); + """, script_args=[url]) + + def is_bookmark_star_button_ready(self): + """Check if the status of the star-button is not updating. + + :returns: True, if the button is ready + """ + return self.marionette.execute_script(""" + let button = window.BookmarkingUI; + + return button.status !== button.STATUS_UPDATING; + """) + + def restore_default_bookmarks(self): + """Restore the default bookmarks for the current profile.""" + retval = self.marionette.execute_async_script(""" + Components.utils.import("resource://gre/modules/BookmarkHTMLUtils.jsm"); + + // Default bookmarks.html file is stored inside omni.jar, + // so get it via a resource URI + let defaultBookmarks = 'chrome://browser/locale/bookmarks.html'; + + // Trigger the import of the default bookmarks + BookmarkHTMLUtils.importFromURL(defaultBookmarks, true) + .then(() => marionetteScriptFinished(true)) + .catch(() => marionetteScriptFinished(false)); + """, script_timeout=10000) + + if not retval: + raise MarionetteException("Restore Default Bookmarks failed") + + # Browser history related helpers # + + def get_all_urls_in_history(self): + """Retrieve any URLs which have been stored in the history.""" + return self.marionette.execute_script(""" + Components.utils.import("resource://gre/modules/PlacesUtils.jsm"); + + let options = PlacesUtils.history.getNewQueryOptions(); + let root = PlacesUtils.history.executeQuery( + PlacesUtils.history.getNewQuery(), options).root; + let urls = []; + + root.containerOpen = true; + for (let i = 0; i < root.childCount; i++) { + urls.push(root.getChild(i).uri) + } + root.containerOpen = false; + + return urls; + """) + + def remove_all_history(self): + """Remove all history items.""" + retval = self.marionette.execute_async_script(""" + Components.utils.import("resource://gre/modules/PlacesUtils.jsm"); + + PlacesUtils.history.clear() + .then(() => marionetteScriptFinished(true)) + .catch(() => marionetteScriptFinished(false)); + """, script_timeout=10000) + + if not retval: + raise MarionetteException("Removing all history failed") + + def wait_for_visited(self, urls, callback): + """Wait until all passed-in urls have been visited. + + :param urls: List of URLs which need to be visited and indexed + + :param callback: Method to execute which triggers loading of the URLs + """ + # Bug 1121691: Needs observer handling support with callback first + # Until then we have to wait about 4s to ensure the page has been indexed + callback() + sleep(4) + + # Plugin related helpers # + + def clear_plugin_data(self): + """Clear any kind of locally stored data from plugins.""" + self.marionette.execute_script(""" + let host = Components.classes["@mozilla.org/plugin/host;1"] + .getService(Components.interfaces.nsIPluginHost); + let tags = host.getPluginTags(); + + tags.forEach(aTag => { + try { + host.clearSiteData(aTag, null, Components.interfaces.nsIPluginHost + .FLAG_CLEAR_ALL, -1); + } catch (ex) { + } + }); + """) diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/security.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/security.py new file mode 100644 index 000000000..7f6532a08 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/security.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/. + +import re + +from firefox_puppeteer.base import BaseLib +from firefox_puppeteer.errors import NoCertificateError + + +class Security(BaseLib): + """Low-level access to security (SSL, TLS) related information.""" + + # Security related helpers # + + def get_address_from_certificate(self, certificate): + """Retrieves the address of the organization from the certificate information. + + The returned address may be `None` in case of no address found, or a dictionary + with the following entries: `city`, `country`, `postal_code`, `state`, `street`. + + :param certificate: A JSON object representing the certificate, which can usually be + retrieved via the current tab: `self.browser.tabbar.selected_tab.certificate`. + + :returns: Address details as dictionary + """ + regex = re.compile('.*?L=(?P<city>.+?),ST=(?P<state>.+?),C=(?P<country>.+?)' + ',postalCode=(?P<postal_code>.+?),STREET=(?P<street>.+?)' + ',serial') + results = regex.search(certificate['subjectName']) + + return results.groupdict() if results else results + + def get_certificate_for_page(self, tab_element): + """The SSL certificate assiciated with the loaded web page in the given tab. + + :param tab_element: The inner tab DOM element. + + :returns: Certificate details as JSON object. + """ + cert = self.marionette.execute_script(""" + var securityUI = arguments[0].linkedBrowser.securityUI; + var status = securityUI.QueryInterface(Components.interfaces.nsISSLStatusProvider) + .SSLStatus; + + return status ? status.serverCert : null; + """, script_args=[tab_element]) + + uri = self.marionette.execute_script(""" + return arguments[0].linkedBrowser.currentURI.spec; + """, script_args=[tab_element]) + + if cert is None: + raise NoCertificateError('No certificate found for "{}"'.format(uri)) + + return cert + + def get_domain_from_common_name(self, common_name): + """Retrieves the domain associated with a page's security certificate from the common name. + + :param certificate: A string containing the certificate's common name, which can usually + be retrieved like so: `certificate['commonName']`. + + :returns: Domain as string + """ + return self.marionette.execute_script(""" + return Services.eTLD.getBaseDomainFromHost(arguments[0]); + """, script_args=[common_name]) diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/software_update.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/software_update.py new file mode 100644 index 000000000..0b90af0d8 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/software_update.py @@ -0,0 +1,411 @@ +# 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 ConfigParser +import os +import re + +import mozinfo + +from firefox_puppeteer.base import BaseLib +from firefox_puppeteer.api.appinfo import AppInfo + + +class ActiveUpdate(BaseLib): + + def __getattr__(self, attr): + value = self.marionette.execute_script(""" + let ums = Components.classes['@mozilla.org/updates/update-manager;1'] + .getService(Components.interfaces.nsIUpdateManager); + return ums.activeUpdate[arguments[0]]; + """, script_args=[attr]) + + if value: + return value + else: + raise AttributeError('{} has no attribute {}'.format(self.__class__.__name__, + attr)) + + @property + def exists(self): + """Checks if there is an active update. + + :returns: True if there is an active update + """ + active_update = self.marionette.execute_script(""" + let ums = Components.classes['@mozilla.org/updates/update-manager;1'] + .getService(Components.interfaces.nsIUpdateManager); + return ums.activeUpdate; + """) + + return bool(active_update) + + def get_patch_at(self, patch_index): + """Use nsIUpdate.getPatchAt to return a patch from an update. + + :returns: JSON data for an nsIUpdatePatch object + """ + return self.marionette.execute_script(""" + let ums = Components.classes['@mozilla.org/updates/update-manager;1'] + .getService(Components.interfaces.nsIUpdateManager); + return ums.activeUpdate.getPatchAt(arguments[0]); + """, script_args=[patch_index]) + + @property + def patch_count(self): + """Get the patchCount from the active update. + + :returns: The patch count + """ + return self.marionette.execute_script(""" + let ums = Components.classes['@mozilla.org/updates/update-manager;1'] + .getService(Components.interfaces.nsIUpdateManager); + return ums.activeUpdate.patchCount; + """) + + @property + def selected_patch(self): + """Get the selected patch for the active update. + + :returns: JSON data for the selected patch + """ + return self.marionette.execute_script(""" + let ums = Components.classes['@mozilla.org/updates/update-manager;1'] + .getService(Components.interfaces.nsIUpdateManager); + return ums.activeUpdate.selectedPatch; + """) + + +class MARChannels(BaseLib): + """Class to handle the allowed MAR channels as listed in update-settings.ini.""" + INI_SECTION = 'Settings' + INI_OPTION = 'ACCEPTED_MAR_CHANNEL_IDS' + + class MARConfigParser(ConfigParser.ConfigParser): + """INI parser which allows to read and write MAR config files. + + Virtually identical to the original method, but delimit keys and values + with '=' instead of ' = ' + """ + + def write(self, fp): + """Write an .ini-format representation of the configuration state.""" + if self._defaults: + fp.write("[%s]\n" % ConfigParser.DEFAULTSECT) + for (key, value) in self._defaults.items(): + fp.write("%s=%s\n" % (key, str(value).replace('\n', '\n\t'))) + fp.write("\n") + for section in self._sections: + fp.write("[%s]\n" % section) + for (key, value) in self._sections[section].items(): + if key == "__name__": + continue + if (value is not None) or (self._optcre == self.OPTCRE): + key = "=".join((key, str(value).replace('\n', '\n\t'))) + fp.write("%s\n" % (key)) + fp.write("\n") + + def __init__(self, marionette): + BaseLib.__init__(self, marionette) + + self.config_file_path = self.marionette.execute_script(""" + Components.utils.import('resource://gre/modules/Services.jsm'); + + let file = Services.dirsvc.get('GreD', Components.interfaces.nsIFile); + file.append('update-settings.ini'); + + return file.path; + """) + + self.config = self.MARConfigParser() + self.config.optionxform = str + + @property + def channels(self): + """The currently accepted MAR channels. + + :returns: A set of channel names + """ + # Make sure to always read the current file contents + self.config.read(self.config_file_path) + + return set(self.config.get(self.INI_SECTION, self.INI_OPTION).split(',')) + + @channels.setter + def channels(self, channels): + """Set the accepted MAR channels. + + :param channels: A set of channel names + """ + self.config.set(self.INI_SECTION, self.INI_OPTION, ','.join(channels)) + with open(self.config_file_path, 'wb') as configfile: + self.config.write(configfile) + + def add_channels(self, channels): + """Add additional MAR channels. + + :param channels: A set of channel names to add + """ + self.channels = self.channels | set(channels) + + def remove_channels(self, channels): + """Remove MAR channels. + + :param channels: A set of channel names to remove + """ + self.channels = self.channels - set(channels) + + +class SoftwareUpdate(BaseLib): + """The SoftwareUpdate API adds support for an easy access to the update process.""" + PREF_APP_DISTRIBUTION = 'distribution.id' + PREF_APP_DISTRIBUTION_VERSION = 'distribution.version' + PREF_APP_UPDATE_CHANNEL = 'app.update.channel' + PREF_APP_UPDATE_URL = 'app.update.url' + PREF_APP_UPDATE_URL_OVERRIDE = 'app.update.url.override' + PREF_DISABLED_ADDONS = 'extensions.disabledAddons' + + def __init__(self, marionette): + BaseLib.__init__(self, marionette) + + self.app_info = AppInfo(marionette) + + self._mar_channels = MARChannels(marionette) + self._active_update = ActiveUpdate(marionette) + + @property + def ABI(self): + """Get the customized ABI for the update service. + + :returns: ABI version + """ + abi = self.app_info.XPCOMABI + if mozinfo.isMac: + abi += self.marionette.execute_script(""" + let macutils = Components.classes['@mozilla.org/xpcom/mac-utils;1'] + .getService(Components.interfaces.nsIMacUtils); + if (macutils.isUniversalBinary) { + return '-u-' + macutils.architecturesInBinary; + } + return ''; + """) + + return abi + + @property + def active_update(self): + """ Holds a reference to an :class:`ActiveUpdate` object.""" + return self._active_update + + @property + def allowed(self): + """Check if the user has permissions to run the software update + + :returns: Status if the user has the permissions + """ + return self.marionette.execute_script(""" + let aus = Components.classes['@mozilla.org/updates/update-service;1'] + .getService(Components.interfaces.nsIApplicationUpdateService); + return aus.canCheckForUpdates && aus.canApplyUpdates; + """) + + @property + def build_info(self): + """Return information of the current build version + + :returns: A dictionary of build information + """ + update_url = self.get_update_url(True) + + return { + 'buildid': self.app_info.appBuildID, + 'channel': self.update_channel, + 'disabled_addons': self.marionette.get_pref(self.PREF_DISABLED_ADDONS), + 'locale': self.app_info.locale, + 'mar_channels': self.mar_channels.channels, + 'update_url': update_url, + 'update_snippet': self.get_update_snippet(update_url), + 'user_agent': self.app_info.user_agent, + 'version': self.app_info.version + } + + @property + def is_complete_update(self): + """Return true if the offered update is a complete update + + :returns: True if the offered update is a complete update + """ + # Throw when isCompleteUpdate is called without an update. This should + # never happen except if the test is incorrectly written. + assert self.active_update.exists, 'An active update has been found' + + patch_count = self.active_update.patch_count + assert patch_count == 1 or patch_count == 2,\ + 'An update must have one or two patches included' + + # Ensure Partial and Complete patches produced have unique urls + if patch_count == 2: + patch0_url = self.active_update.get_patch_at(0)['URL'] + patch1_url = self.active_update.get_patch_at(1)['URL'] + assert patch0_url != patch1_url,\ + 'Partial and Complete download URLs are different' + + return self.active_update.selected_patch['type'] == 'complete' + + @property + def mar_channels(self): + """ Holds a reference to a :class:`MARChannels` object.""" + return self._mar_channels + + @property + def os_version(self): + """Returns information about the OS version + + :returns: The OS version + """ + return self.marionette.execute_script(""" + Components.utils.import("resource://gre/modules/Services.jsm"); + + let osVersion; + try { + osVersion = Services.sysinfo.getProperty("name") + " " + + Services.sysinfo.getProperty("version"); + } + catch (ex) { + } + + if (osVersion) { + try { + osVersion += " (" + Services.sysinfo.getProperty("secondaryLibrary") + ")"; + } + catch (e) { + // Not all platforms have a secondary widget library, + // so an error is nothing to worry about. + } + osVersion = encodeURIComponent(osVersion); + } + return osVersion; + """) + + @property + def patch_info(self): + """ Returns information of the active update in the queue.""" + info = {'channel': self.update_channel} + + if (self.active_update.exists): + info['buildid'] = self.active_update.buildID + info['is_complete'] = self.is_complete_update + info['size'] = self.active_update.selected_patch['size'] + info['type'] = self.update_type + info['url_mirror'] = \ + self.active_update.selected_patch['finalURL'] or 'n/a' + info['version'] = self.active_update.appVersion + + return info + + @property + def staging_directory(self): + """ Returns the path to the updates staging directory.""" + return self.marionette.execute_script(""" + let aus = Components.classes['@mozilla.org/updates/update-service;1'] + .getService(Components.interfaces.nsIApplicationUpdateService); + return aus.getUpdatesDirectory().path; + """) + + @property + def update_channel(self): + """Return the currently used update channel.""" + return self.marionette.get_pref(self.PREF_APP_UPDATE_CHANNEL, + default_branch=True) + + @update_channel.setter + def update_channel(self, channel): + """Set the update channel to be used for update checks. + + :param channel: New update channel to use + + """ + writer = UpdateChannelWriter(self.marionette) + writer.set_channel(channel) + + @property + def update_type(self): + """Returns the type of the active update.""" + return self.active_update.type + + def force_fallback(self): + """Update the update.status file and set the status to 'failed:6'""" + with open(os.path.join(self.staging_directory, 'update.status'), 'w') as f: + f.write('failed: 6\n') + + def get_update_snippet(self, update_url): + """Retrieve contents of the update snippet. + + :param update_url: URL to the update snippet + """ + snippet = None + try: + import urllib2 + response = urllib2.urlopen(update_url) + snippet = response.read() + except Exception: + pass + + return snippet + + def get_update_url(self, force=False): + """Retrieve the AUS update URL the update snippet is retrieved from. + + :param force: Boolean flag to force an update check + + :returns: The URL of the update snippet + """ + url = self.marionette.get_pref(self.PREF_APP_UPDATE_URL_OVERRIDE) + if not url: + url = self.marionette.get_pref(self.PREF_APP_UPDATE_URL) + + # Format the URL by replacing placeholders + url = self.marionette.execute_script(""" + Components.utils.import("resource://gre/modules/UpdateUtils.jsm") + return UpdateUtils.formatUpdateURL(arguments[0]); + """, script_args=[url]) + + if force: + if '?' in url: + url += '&' + else: + url += '?' + url += 'force=1' + + return url + + +class UpdateChannelWriter(BaseLib): + """Class to handle the update channel as listed in channel-prefs.js""" + REGEX_UPDATE_CHANNEL = re.compile(r'("app\.update\.channel", ")([^"].*)(?=")') + + def __init__(self, *args, **kwargs): + BaseLib.__init__(self, *args, **kwargs) + + self.file_path = self.marionette.execute_script(""" + Components.utils.import('resource://gre/modules/Services.jsm'); + + let file = Services.dirsvc.get('PrfDef', Components.interfaces.nsIFile); + file.append('channel-prefs.js'); + + return file.path; + """) + + def set_channel(self, channel): + """Set default update channel. + + :param channel: New default update channel + """ + with open(self.file_path) as f: + file_contents = f.read() + + new_content = re.sub( + self.REGEX_UPDATE_CHANNEL, r'\g<1>' + channel, file_contents) + with open(self.file_path, 'w') as f: + f.write(new_content) diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/utils.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/utils.py new file mode 100644 index 000000000..2b4ef0766 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/api/utils.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/. + +from marionette_driver.errors import MarionetteException + +from firefox_puppeteer.base import BaseLib + + +class Utils(BaseLib): + """Low-level access to utility actions.""" + + def __init__(self, *args, **kwargs): + super(Utils, self).__init__(*args, **kwargs) + + self._permissions = Permissions(self.marionette) + + @property + def permissions(self): + """Handling of various permissions for hosts. + + :returns: Instance of the Permissions class. + """ + return self._permissions + + def compare_version(self, a, b): + """Compare two version strings. + + :param a: The first version. + :param b: The second version. + + :returns: -1 if a is smaller than b, 0 if equal, and 1 if larger. + """ + return self.marionette.execute_script(""" + Components.utils.import("resource://gre/modules/Services.jsm"); + return Services.vc.compare(arguments[0], arguments[1]); + """, script_args=[a, b]) + + def sanitize(self, data_type): + """Sanitize user data, including cache, cookies, offlineApps, history, formdata, + downloads, passwords, sessions, siteSettings. + + Usage: + sanitize(): Clears all user data. + sanitize({ "sessions": True }): Clears only session user data. + + more: https://dxr.mozilla.org/mozilla-central/source/browser/base/content/sanitize.js + + :param data_type: optional, Information specifying data to be sanitized + """ + + with self.marionette.using_context('chrome'): + result = self.marionette.execute_async_script(""" + Components.utils.import("resource://gre/modules/Services.jsm"); + + var data_type = arguments[0]; + + var data_type = (typeof data_type === "undefined") ? {} : { + cache: data_type.cache || false, + cookies: data_type.cookies || false, + downloads: data_type.downloads || false, + formdata: data_type.formdata || false, + history: data_type.history || false, + offlineApps: data_type.offlineApps || false, + passwords: data_type.passwords || false, + sessions: data_type.sessions || false, + siteSettings: data_type.siteSettings || false + }; + + // Load the sanitize script + var tempScope = {}; + Components.classes["@mozilla.org/moz/jssubscript-loader;1"] + .getService(Components.interfaces.mozIJSSubScriptLoader) + .loadSubScript("chrome://browser/content/sanitize.js", tempScope); + + // Instantiate the Sanitizer + var s = new tempScope.Sanitizer(); + s.prefDomain = "privacy.cpd."; + var itemPrefs = Services.prefs.getBranch(s.prefDomain); + + // Apply options for what to sanitize + for (var pref in data_type) { + itemPrefs.setBoolPref(pref, data_type[pref]); + }; + + // Sanitize and wait for the promise to resolve + var finished = false; + s.sanitize().then(() => { + for (let pref in data_type) { + itemPrefs.clearUserPref(pref); + }; + marionetteScriptFinished(true); + }, aError => { + for (let pref in data_type) { + itemPrefs.clearUserPref(pref); + }; + marionetteScriptFinished(false); + }); + """, script_args=[data_type]) + + if not result: + raise MarionetteException('Sanitizing of profile data failed.') + + +class Permissions(BaseLib): + + def add(self, host, permission): + """Add a permission for web host. + + Permissions include safe-browsing, install, geolocation, and others described here: + https://dxr.mozilla.org/mozilla-central/source/browser/modules/SitePermissions.jsm#144 + and elsewhere. + + :param host: The web host whose permission will be added. + :param permission: The type of permission to be added. + """ + with self.marionette.using_context('chrome'): + self.marionette.execute_script(""" + Components.utils.import("resource://gre/modules/Services.jsm"); + let uri = Services.io.newURI(arguments[0], null, null); + Services.perms.add(uri, arguments[1], + Components.interfaces.nsIPermissionManager.ALLOW_ACTION); + """, script_args=[host, permission]) + + def remove(self, host, permission): + """Remove a permission for web host. + + Permissions include safe-browsing, install, geolocation, and others described here: + https://dxr.mozilla.org/mozilla-central/source/browser/modules/SitePermissions.jsm#144 + and elsewhere. + + :param host: The web host whose permission will be removed. + :param permission: The type of permission to be removed. + """ + with self.marionette.using_context('chrome'): + self.marionette.execute_script(""" + Components.utils.import("resource://gre/modules/Services.jsm"); + let uri = Services.io.newURI(arguments[0], null, null); + Services.perms.remove(uri, arguments[1]); + """, script_args=[host, permission]) diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/base.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/base.py new file mode 100644 index 000000000..8c805e2b5 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/base.py @@ -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/. + + +class BaseLib(object): + """A base class that handles lazily setting the "client" class attribute.""" + + def __init__(self, marionette): + self._marionette = marionette + + @property + def marionette(self): + return self._marionette diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/decorators.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/decorators.py new file mode 100644 index 000000000..1cdb64b00 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/decorators.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 functools import wraps +from importlib import import_module + + +class use_class_as_property(object): + """ + This decorator imports a library module and sets an instance + of the associated class as an attribute on the Puppeteer + object and returns it. + + Note: return value of the wrapped function is ignored. + """ + def __init__(self, lib): + self.lib = lib + self.mod_name, self.cls_name = self.lib.rsplit('.', 1) + + def __call__(self, func): + @property + @wraps(func) + def _(cls, *args, **kwargs): + tag = '_{}_{}'.format(self.mod_name, self.cls_name) + prop = getattr(cls, tag, None) + + if not prop: + module = import_module('.{}'.format(self.mod_name), + 'firefox_puppeteer') + prop = getattr(module, self.cls_name)(cls.marionette) + setattr(cls, tag, prop) + func(cls, *args, **kwargs) + return prop + return _ diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/errors.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/errors.py new file mode 100644 index 000000000..a518422e9 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/errors.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_driver.errors import MarionetteException + + +class NoCertificateError(MarionetteException): + pass + + +class UnexpectedWindowTypeError(MarionetteException): + pass + + +class UnknownTabError(MarionetteException): + pass + + +class UnknownWindowError(MarionetteException): + pass diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/mixins.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/mixins.py new file mode 100644 index 000000000..645aa40a3 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/mixins.py @@ -0,0 +1,101 @@ +# 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 firefox_puppeteer.puppeteer import Puppeteer +from firefox_puppeteer.ui.browser.window import BrowserWindow + + +class PuppeteerMixin(object): + """Mix-in class for Firefox specific API modules exposed to test scope. + + It also provides common set-up and tear-down code for Firefox tests. + + Child test case classes are expected to also subclass MarionetteTestCase such + that PuppeteerMixin is followed by MarionetteTestCase. This will insert the + Puppeteer mixin before the MarionetteTestCase into the MRO. + + example: + `class MyTestCase(PuppeteerMixin, MarionetteTestCase)` + + The key role of MarionetteTestCase is to set self.marionette appropriately + in `setUp()`. Any TestCase class that satisfies this requirement is + compatible with this class. + + If you're extending the inheritance tree further to make specialized + TestCases, favour the use of super() as opposed to explicit calls to a + parent class. + + """ + def _check_and_fix_leaked_handles(self): + handle_count = len(self.marionette.window_handles) + url = [] + + try: + # Verify the existence of leaked tabs and print their URLs. + if self._start_handle_count < handle_count: + message = ('A test must not leak window handles. This test started with ' + '%s open top level browsing contexts, but ended with %s.' + ' Remaining Tabs URLs:') % (self._start_handle_count, handle_count) + with self.marionette.using_context('content'): + for tab in self.marionette.window_handles: + if tab not in self._init_tab_handles: + url.append(' %s' % self.marionette.get_url()) + self.assertListEqual(self._init_tab_handles, self.marionette.window_handles, + message + ','.join(url)) + finally: + # For clean-up make sure we work on a proper browser window + if not self.browser or self.browser.closed: + # Find a proper replacement browser window + # TODO: We have to make this less error prone in case no browser is open. + self.browser = self.puppeteer.windows.switch_to( + lambda win: type(win) is BrowserWindow) + + # Ensure to close all the remaining chrome windows to give following + # tests a proper start condition and make them not fail. + self.puppeteer.windows.close_all([self.browser]) + self.browser.focus() + + # Also close all remaining tabs + self.browser.tabbar.close_all_tabs([self.browser.tabbar.tabs[0]]) + self.browser.tabbar.tabs[0].switch_to() + + def restart(self, **kwargs): + """Restart Firefox and re-initialize data. + + :param flags: Specific restart flags for Firefox + """ + if kwargs.get('clean'): + self.marionette.restart(clean=True) + else: + self.marionette.restart(in_app=True) + + # Ensure that we always have a valid browser instance available + self.browser = self.puppeteer.windows.switch_to(lambda win: type(win) is BrowserWindow) + + def setUp(self, *args, **kwargs): + super(PuppeteerMixin, self).setUp(*args, **kwargs) + + self._start_handle_count = len(self.marionette.window_handles) + self._init_tab_handles = self.marionette.window_handles + self.marionette.set_context('chrome') + + self.puppeteer = Puppeteer(self.marionette) + self.browser = self.puppeteer.windows.current + self.browser.focus() + + with self.marionette.using_context(self.marionette.CONTEXT_CONTENT): + # Bug 1312674 - Navigating to about:blank twice can cause a hang in + # Marionette. So try to always have a known default page loaded. + self.marionette.navigate('about:') + + def tearDown(self, *args, **kwargs): + self.marionette.set_context('chrome') + + try: + # This code should be run after all other tearDown code + # so that in case of a failure, further tests will not run + # in a state that is more inconsistent than necessary. + self._check_and_fix_leaked_handles() + finally: + super(PuppeteerMixin, self).tearDown(*args, **kwargs) diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/puppeteer.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/puppeteer.py new file mode 100644 index 000000000..1894a414b --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/puppeteer.py @@ -0,0 +1,84 @@ +# 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 decorators import use_class_as_property + + +class Puppeteer(object): + """The puppeteer class is used to expose additional API and UI libraries. + + Each library can be referenced by its puppeteer name as a member of a + Puppeteer instance. For example, the `current_window` member of the + `Browser` class can be accessed via `puppeteer.browser.current_window`. + """ + + def __init__(self, marionette): + self._marionette = marionette + + @property + def marionette(self): + return self._marionette + + @use_class_as_property('api.appinfo.AppInfo') + def appinfo(self): + """ + Provides access to members of the appinfo api. + + See the :class:`~api.appinfo.AppInfo` reference. + """ + + @use_class_as_property('api.keys.Keys') + def keys(self): + """ + Provides a definition of control keys to use with keyboard shortcuts. + For example, keys.CONTROL or keys.ALT. + See the :class:`~api.keys.Keys` reference. + """ + + @use_class_as_property('api.places.Places') + def places(self): + """Provides low-level access to several bookmark and history related actions. + + See the :class:`~api.places.Places` reference. + """ + + @use_class_as_property('api.utils.Utils') + def utils(self): + """Provides an api for interacting with utility actions. + + See the :class:`~api.utils.Utils` reference. + """ + + @property + def platform(self): + """Returns the lowercased platform name. + + :returns: Platform name + """ + return self.marionette.session_capabilities['platformName'] + + @use_class_as_property('api.prefs.Preferences') + def prefs(self): + """ + Provides an api for setting and inspecting preferences, as see in + about:config. + + See the :class:`~api.prefs.Preferences` reference. + """ + + @use_class_as_property('api.security.Security') + def security(self): + """ + Provides an api for accessing security related properties and helpers. + + See the :class:`~api.security.Security` reference. + """ + + @use_class_as_property('ui.windows.Windows') + def windows(self): + """ + Provides shortcuts to the top-level windows. + + See the :class:`~ui.window.Windows` reference. + """ diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/__init__.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/__init__.py diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/about_window/__init__.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/about_window/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/about_window/__init__.py diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/about_window/deck.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/about_window/deck.py new file mode 100644 index 000000000..9d8d90603 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/about_window/deck.py @@ -0,0 +1,174 @@ +# 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 firefox_puppeteer.ui.base import UIBaseLib +from firefox_puppeteer.ui.deck import Panel + + +class Deck(UIBaseLib): + + def _create_panel_for_id(self, panel_id): + """Creates an instance of :class:`Panel` for the specified panel id. + + :param panel_id: The ID of the panel to create an instance of. + + :returns: :class:`Panel` instance + """ + mapping = {'apply': ApplyPanel, + 'checkForUpdates': CheckForUpdatesPanel, + 'checkingForUpdates': CheckingForUpdatesPanel, + 'downloadAndInstall': DownloadAndInstallPanel, + 'downloadFailed': DownloadFailedPanel, + 'downloading': DownloadingPanel, + 'noUpdatesFound': NoUpdatesFoundPanel, + } + + panel = self.element.find_element(By.ID, panel_id) + return mapping.get(panel_id, Panel)(self.marionette, self.window, panel) + + # Properties for visual elements of the deck # + + @property + def apply(self): + """The :class:`ApplyPanel` instance for the apply panel. + + :returns: :class:`ApplyPanel` instance. + """ + return self._create_panel_for_id('apply') + + @property + def check_for_updates(self): + """The :class:`CheckForUpdatesPanel` instance for the check for updates panel. + + :returns: :class:`CheckForUpdatesPanel` instance. + """ + return self._create_panel_for_id('checkForUpdates') + + @property + def checking_for_updates(self): + """The :class:`CheckingForUpdatesPanel` instance for the checking for updates panel. + + :returns: :class:`CheckingForUpdatesPanel` instance. + """ + return self._create_panel_for_id('checkingForUpdates') + + @property + def download_and_install(self): + """The :class:`DownloadAndInstallPanel` instance for the download and install panel. + + :returns: :class:`DownloadAndInstallPanel` instance. + """ + return self._create_panel_for_id('downloadAndInstall') + + @property + def download_failed(self): + """The :class:`DownloadFailedPanel` instance for the download failed panel. + + :returns: :class:`DownloadFailedPanel` instance. + """ + return self._create_panel_for_id('downloadFailed') + + @property + def downloading(self): + """The :class:`DownloadingPanel` instance for the downloading panel. + + :returns: :class:`DownloadingPanel` instance. + """ + return self._create_panel_for_id('downloading') + + @property + def no_updates_found(self): + """The :class:`NoUpdatesFoundPanel` instance for the no updates found panel. + + :returns: :class:`NoUpdatesFoundPanel` instance. + """ + return self._create_panel_for_id('noUpdatesFound') + + @property + def panels(self): + """List of all the :class:`Panel` instances of the current deck. + + :returns: List of :class:`Panel` instances. + """ + panels = self.marionette.execute_script(""" + let deck = arguments[0]; + let panels = []; + + for (let index = 0; index < deck.children.length; index++) { + if (deck.children[index].id) { + panels.push(deck.children[index].id); + } + } + + return panels; + """, script_args=[self.element]) + + return [self._create_panel_for_id(panel) for panel in panels] + + @property + def selected_index(self): + """The index of the currently selected panel. + + :return: Index of the selected panel. + """ + return int(self.element.get_property('selectedIndex')) + + @property + def selected_panel(self): + """A :class:`Panel` instance of the currently selected panel. + + :returns: :class:`Panel` instance. + """ + return self.panels[self.selected_index] + + +class ApplyPanel(Panel): + + @property + def button(self): + """The DOM element which represents the Update button. + + :returns: Reference to the button element. + """ + return self.element.find_element(By.ID, 'updateButton') + + +class CheckForUpdatesPanel(Panel): + + @property + def button(self): + """The DOM element which represents the Check for Updates button. + + :returns: Reference to the button element. + """ + return self.element.find_element(By.ID, 'checkForUpdatesButton') + + +class CheckingForUpdatesPanel(Panel): + pass + + +class DownloadAndInstallPanel(Panel): + + @property + def button(self): + """The DOM element which represents the Download button. + + :returns: Reference to the button element. + """ + return self.element.find_element(By.ID, 'downloadAndInstallButton') + + +class DownloadFailedPanel(Panel): + pass + + +class DownloadingPanel(Panel): + pass + + +class NoUpdatesFoundPanel(Panel): + pass diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/about_window/window.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/about_window/window.py new file mode 100644 index 000000000..25037a471 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/about_window/window.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/. + +from marionette_driver import By + +from firefox_puppeteer.ui.about_window.deck import Deck +from firefox_puppeteer.ui.windows import BaseWindow, Windows + + +class AboutWindow(BaseWindow): + """Representation of the About window.""" + window_type = 'Browser:About' + + dtds = [ + 'chrome://branding/locale/brand.dtd', + 'chrome://browser/locale/aboutDialog.dtd', + ] + + @property + def deck(self): + """The :class:`Deck` instance which represents the deck. + + :returns: Reference to the deck. + """ + self.switch_to() + + deck = self.window_element.find_element(By.ID, 'updateDeck') + return Deck(self.marionette, self, deck) + + +Windows.register_window(AboutWindow.window_type, AboutWindow) diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/base.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/base.py new file mode 100644 index 000000000..622568df9 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/base.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/. + +from marionette_driver.marionette import HTMLElement + +from firefox_puppeteer.base import BaseLib +from firefox_puppeteer.ui.windows import BaseWindow + + +class UIBaseLib(BaseLib): + """A base class for all UI element wrapper classes inside a chrome window.""" + + def __init__(self, marionette, window, element): + super(UIBaseLib, self).__init__(marionette) + + assert isinstance(window, BaseWindow) + assert isinstance(element, HTMLElement) + + self._window = window + self._element = element + + @property + def element(self): + """Returns the reference to the underlying DOM element. + + :returns: Reference to the DOM element + """ + return self._element + + @property + def window(self): + """Returns the reference to the chrome window. + + :returns: :class:`BaseWindow` instance of the chrome window. + """ + return self._window + + +class DOMElement(HTMLElement): + """ + Class that inherits from HTMLElement and provides a way for subclasses to + expose new api's. + """ + + def __new__(cls, element): + instance = object.__new__(cls) + instance.__dict__ = element.__dict__.copy() + setattr(instance, 'inner', element) + + return instance + + def __init__(self, element): + pass diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/browser/__init__.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/browser/__init__.py new file mode 100644 index 000000000..c580d191c --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/browser/__init__.py @@ -0,0 +1,3 @@ +# 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/. diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/browser/notifications.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/browser/notifications.py new file mode 100644 index 000000000..2cf67ce7f --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/browser/notifications.py @@ -0,0 +1,116 @@ +# 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 abc import ABCMeta + +from marionette_driver import By + +from firefox_puppeteer.ui.base import UIBaseLib + + +class BaseNotification(UIBaseLib): + """Abstract base class for any kind of notification.""" + + __metaclass__ = ABCMeta + + @property + def close_button(self): + """Provide access to the close button. + + :returns: The close button. + """ + return self.element.find_element(By.ANON_ATTRIBUTE, + {'anonid': 'closebutton'}) + + @property + def label(self): + """Provide access to the notification label. + + :returns: The notification label. + """ + return self.element.get_attribute('label') + + @property + def origin(self): + """Provide access to the notification origin. + + :returns: The notification origin. + """ + return self.element.get_attribute('origin') + + def close(self, force=False): + """Close the notification. + + :param force: Optional, if True force close the notification. + Defaults to False. + """ + if force: + self.marionette.execute_script('arguments[0].click()', + script_args=[self.close_button]) + else: + self.close_button.click() + + self.window.wait_for_notification(None) + + +class AddOnInstallBlockedNotification(BaseNotification): + """Add-on install blocked notification.""" + + @property + def allow_button(self): + """Provide access to the allow button. + + :returns: The allow button. + """ + return self.element.find_element( + By.ANON_ATTRIBUTE, {'anonid': 'button'}).find_element( + By.ANON_ATTRIBUTE, {'anonid': 'button'}) + + +class AddOnInstallConfirmationNotification(BaseNotification): + """Add-on install confirmation notification.""" + + @property + def addon_name(self): + """Provide access to the add-on name. + + :returns: The add-on name. + """ + label = self.element.find_element( + By.CSS_SELECTOR, '#addon-install-confirmation-content label') + return label.get_attribute('value') + + def cancel_button(self): + """Provide access to the cancel button. + + :returns: The cancel button. + """ + return self.element.find_element( + By.ID, 'addon-install-confirmation-cancel') + + def install_button(self): + """Provide access to the install button. + + :returns: The install button. + """ + return self.element.find_element( + By.ID, 'addon-install-confirmation-accept') + + +class AddOnInstallCompleteNotification(BaseNotification): + """Add-on install complete notification.""" + + pass + + +class AddOnInstallFailedNotification(BaseNotification): + """Add-on install failed notification.""" + + pass + + +class AddOnProgressNotification(BaseNotification): + """Add-on progress notification.""" + + pass diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/browser/tabbar.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/browser/tabbar.py new file mode 100644 index 000000000..4fec98d99 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/browser/tabbar.py @@ -0,0 +1,388 @@ +# 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, Wait +) + +from marionette_driver.errors import NoSuchElementException + +import firefox_puppeteer.errors as errors + +from firefox_puppeteer.api.security import Security +from firefox_puppeteer.ui.base import UIBaseLib, DOMElement + + +class TabBar(UIBaseLib): + """Wraps the tabs toolbar DOM element inside a browser window.""" + + # Properties for visual elements of the tabs toolbar # + + @property + def menupanel(self): + """A :class:`MenuPanel` instance which represents the menu panel + at the far right side of the tabs toolbar. + + :returns: :class:`MenuPanel` instance. + """ + return MenuPanel(self.marionette, self.window) + + @property + def newtab_button(self): + """The DOM element which represents the new tab button. + + :returns: Reference to the new tab button. + """ + return self.toolbar.find_element(By.ANON_ATTRIBUTE, {'anonid': 'tabs-newtab-button'}) + + @property + def tabs(self): + """List of all the :class:`Tab` instances of the current browser window. + + :returns: List of :class:`Tab` instances. + """ + tabs = self.toolbar.find_elements(By.TAG_NAME, 'tab') + + return [Tab(self.marionette, self.window, tab) for tab in tabs] + + @property + def toolbar(self): + """The DOM element which represents the tab toolbar. + + :returns: Reference to the tabs toolbar. + """ + return self.element + + # Properties for helpers when working with the tabs toolbar # + + @property + def selected_index(self): + """The index of the currently selected tab. + + :return: Index of the selected tab. + """ + return int(self.toolbar.get_property('selectedIndex')) + + @property + def selected_tab(self): + """A :class:`Tab` instance of the currently selected tab. + + :returns: :class:`Tab` instance. + """ + return self.tabs[self.selected_index] + + # Methods for helpers when working with the tabs toolbar # + + def close_all_tabs(self, exceptions=None): + """Forces closing of all open tabs. + + There is an optional `exceptions` list, which can be used to exclude + specific tabs from being closed. + + :param exceptions: Optional, list of :class:`Tab` instances not to close. + """ + # Get handles from tab exceptions, and find those which can be closed + for tab in self.tabs: + if tab not in exceptions: + tab.close(force=True) + + def close_tab(self, tab=None, trigger='menu', force=False): + """Closes the tab by using the specified trigger. + + By default the currently selected tab will be closed. If another :class:`Tab` + is specified, that one will be closed instead. Also when the tab is closed, a + :func:`switch_to` call is automatically performed, so that the new selected + tab becomes active. + + :param tab: Optional, the :class:`Tab` instance to close. Defaults to + the currently selected tab. + + :param trigger: Optional, method to close the current tab. This can + be a string with one of `menu` or `shortcut`, or a callback which gets triggered + with the :class:`Tab` as parameter. Defaults to `menu`. + + :param force: Optional, forces the closing of the window by using the Gecko API. + Defaults to `False`. + """ + tab = tab or self.selected_tab + tab.close(trigger, force) + + def open_tab(self, trigger='menu'): + """Opens a new tab in the current browser window. + + If the tab opens in the foreground, a call to :func:`switch_to` will + automatically be performed. But if it opens in the background, the current + tab will keep its focus. + + :param trigger: Optional, method to open the new tab. This can + be a string with one of `menu`, `button` or `shortcut`, or a callback + which gets triggered with the current :class:`Tab` as parameter. + Defaults to `menu`. + + :returns: :class:`Tab` instance for the opened tab. + """ + start_handles = self.marionette.window_handles + + # Prepare action which triggers the opening of the browser window + if callable(trigger): + trigger(self.selected_tab) + elif trigger == 'button': + self.window.tabbar.newtab_button.click() + elif trigger == 'menu': + self.window.menubar.select_by_id('file-menu', + 'menu_newNavigatorTab') + elif trigger == 'shortcut': + self.window.send_shortcut(self.window.localize_entity('tabCmd.commandkey'), + accel=True) + # elif - need to add other cases + else: + raise ValueError('Unknown opening method: "%s"' % trigger) + + # TODO: Needs to be replaced with event handling code (bug 1121705) + Wait(self.marionette).until( + lambda mn: len(mn.window_handles) == len(start_handles) + 1, + message='No new tab has been opened.') + + handles = self.marionette.window_handles + [new_handle] = list(set(handles) - set(start_handles)) + [new_tab] = [tab for tab in self.tabs if tab.handle == new_handle] + + # if the new tab is the currently selected tab, switch to it + if new_tab == self.selected_tab: + new_tab.switch_to() + + return new_tab + + def switch_to(self, target): + """Switches the context to the specified tab. + + :param target: The tab to switch to. `target` can be an index, a :class:`Tab` + instance, or a callback that returns True in the context of the desired tab. + + :returns: Instance of the selected :class:`Tab`. + """ + start_handle = self.marionette.current_window_handle + + if isinstance(target, int): + return self.tabs[target].switch_to() + elif isinstance(target, Tab): + return target.switch_to() + if callable(target): + for tab in self.tabs: + tab.switch_to() + if target(tab): + return tab + + self.marionette.switch_to_window(start_handle) + raise errors.UnknownTabError("No tab found for '{}'".format(target)) + + raise ValueError("The 'target' parameter must either be an index or a callable") + + @staticmethod + def get_handle_for_tab(marionette, tab_element): + """Retrieves the marionette handle for the given :class:`Tab` instance. + + :param marionette: An instance of the Marionette client. + + :param tab_element: The DOM element corresponding to a tab inside the tabs toolbar. + + :returns: `handle` of the tab. + """ + # TODO: This introduces coupling with marionette's window handles + # implementation. To avoid this, the capacity to get the XUL + # element corresponding to the active window according to + # marionette or a similar ability should be added to marionette. + handle = marionette.execute_script(""" + let win = arguments[0].linkedBrowser; + if (!win) { + return null; + } + return win.outerWindowID.toString(); + """, script_args=[tab_element]) + + return handle + + +class Tab(UIBaseLib): + """Wraps a tab DOM element.""" + + def __init__(self, marionette, window, element): + super(Tab, self).__init__(marionette, window, element) + + self._security = Security(self.marionette) + self._handle = None + + # Properties for visual elements of tabs # + + @property + def close_button(self): + """The DOM element which represents the tab close button. + + :returns: Reference to the tab close button. + """ + return self.tab_element.find_element(By.ANON_ATTRIBUTE, {'anonid': 'close-button'}) + + @property + def tab_element(self): + """The inner tab DOM element. + + :returns: Tab DOM element. + """ + return self.element + + # Properties for backend values + + @property + def location(self): + """Returns the current URL + + :returns: Current URL + """ + self.switch_to() + + return self.marionette.execute_script(""" + return arguments[0].linkedBrowser.currentURI.spec; + """, script_args=[self.tab_element]) + + @property + def certificate(self): + """The SSL certificate assiciated with the loaded web page. + + :returns: Certificate details as JSON blob. + """ + self.switch_to() + + return self._security.get_certificate_for_page(self.tab_element) + + # Properties for helpers when working with tabs # + + @property + def handle(self): + """The `handle` of the content window. + + :returns: content window `handle`. + """ + # If no handle has been set yet, wait until it is available + if not self._handle: + self._handle = Wait(self.marionette).until( + lambda mn: TabBar.get_handle_for_tab(mn, self.element), + message='Tab handle could not be found.') + + return self._handle + + @property + def selected(self): + """Checks if the tab is selected. + + :return: `True` if the tab is selected. + """ + return self.marionette.execute_script(""" + return arguments[0].hasAttribute('selected'); + """, script_args=[self.tab_element]) + + # Methods for helpers when working with tabs # + + def __eq__(self, other): + return self.handle == other.handle + + def close(self, trigger='menu', force=False): + """Closes the tab by using the specified trigger. + + When the tab is closed a :func:`switch_to` call is automatically performed, so that + the new selected tab becomes active. + + :param trigger: Optional, method in how to close the tab. This can + be a string with one of `button`, `menu` or `shortcut`, or a callback which + gets triggered with the current :class:`Tab` as parameter. Defaults to `menu`. + + :param force: Optional, forces the closing of the window by using the Gecko API. + Defaults to `False`. + """ + handle = self.handle + start_handles = self.marionette.window_handles + + self.switch_to() + + if force: + self.marionette.close() + elif callable(trigger): + trigger(self) + elif trigger == 'button': + self.close_button.click() + elif trigger == 'menu': + self.window.menubar.select_by_id('file-menu', 'menu_close') + elif trigger == 'shortcut': + self.window.send_shortcut(self.window.localize_entity('closeCmd.key'), + accel=True) + else: + raise ValueError('Unknown closing method: "%s"' % trigger) + + Wait(self.marionette).until( + lambda _: len(self.window.tabbar.tabs) == len(start_handles) - 1, + message='Tab with handle "%s" has not been closed.' % handle) + + # Ensure to switch to the window handle which represents the new selected tab + self.window.tabbar.selected_tab.switch_to() + + def select(self): + """Selects the tab and sets the focus to it.""" + self.tab_element.click() + self.switch_to() + + # Bug 1121705: Maybe we have to wait for TabSelect event + Wait(self.marionette).until( + lambda _: self.selected, + message='Tab with handle "%s" could not be selected.' % self.handle) + + def switch_to(self): + """Switches the context of Marionette to this tab. + + Please keep in mind that calling this method will not select the tab. + Use the :func:`~Tab.select` method instead. + """ + self.marionette.switch_to_window(self.handle) + + +class MenuPanel(UIBaseLib): + + @property + def popup(self): + """ + :returns: The :class:`MenuPanelElement`. + """ + popup = self.marionette.find_element(By.ID, 'PanelUI-popup') + return self.MenuPanelElement(popup) + + class MenuPanelElement(DOMElement): + """Wraps the menu panel.""" + _buttons = None + + @property + def buttons(self): + """ + :returns: A list of all the clickable buttons in the menu panel. + """ + if not self._buttons: + self._buttons = (self.find_element(By.ID, 'PanelUI-multiView') + .find_element(By.ANON_ATTRIBUTE, + {'anonid': 'viewContainer'}) + .find_elements(By.TAG_NAME, + 'toolbarbutton')) + return self._buttons + + def click(self, target=None): + """ + Overrides HTMLElement.click to provide a target to click. + + :param target: The label associated with the button to click on, + e.g., `New Private Window`. + """ + if not target: + return DOMElement.click(self) + + for button in self.buttons: + if button.get_attribute('label') == target: + return button.click() + raise NoSuchElementException('Could not find "{}"" in the ' + 'menu panel UI'.format(target)) diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/browser/toolbars.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/browser/toolbars.py new file mode 100644 index 000000000..d490e488f --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/browser/toolbars.py @@ -0,0 +1,641 @@ +# 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, keys, Wait + +from firefox_puppeteer.ui.base import UIBaseLib + + +class NavBar(UIBaseLib): + """Provides access to the DOM elements contained in the + navigation bar as well as the location bar.""" + + def __init__(self, *args, **kwargs): + super(NavBar, self).__init__(*args, **kwargs) + + self._locationbar = None + + @property + def back_button(self): + """Provides access to the DOM element back button in the navbar. + + :returns: Reference to the back button. + """ + return self.marionette.find_element(By.ID, 'back-button') + + @property + def forward_button(self): + """Provides access to the DOM element forward button in the navbar. + + :returns: Reference to the forward button. + """ + return self.marionette.find_element(By.ID, 'forward-button') + + @property + def home_button(self): + """Provides access to the DOM element home button in the navbar. + + :returns: Reference to the home button element + """ + return self.marionette.find_element(By.ID, 'home-button') + + @property + def locationbar(self): + """Provides access to the DOM elements contained in the + locationbar. + + See the :class:`LocationBar` reference. + """ + if not self._locationbar: + urlbar = self.marionette.find_element(By.ID, 'urlbar') + self._locationbar = LocationBar(self.marionette, self.window, urlbar) + + return self._locationbar + + @property + def menu_button(self): + """Provides access to the DOM element menu button in the navbar. + + :returns: Reference to the menu button element. + """ + return self.marionette.find_element(By.ID, 'PanelUI-menu-button') + + @property + def toolbar(self): + """The DOM element which represents the navigation toolbar. + + :returns: Reference to the navigation toolbar. + """ + return self.element + + +class LocationBar(UIBaseLib): + """Provides access to and methods for the DOM elements contained in the + locationbar (the text area of the ui that typically displays the current url).""" + + def __init__(self, *args, **kwargs): + super(LocationBar, self).__init__(*args, **kwargs) + + self._autocomplete_results = None + self._identity_popup = None + + @property + def autocomplete_results(self): + """Provides access to and methods for the location bar + autocomplete results. + + See the :class:`AutocompleteResults` reference.""" + if not self._autocomplete_results: + popup = self.marionette.find_element(By.ID, 'PopupAutoCompleteRichResult') + self._autocomplete_results = AutocompleteResults(self.marionette, + self.window, popup) + + return self._autocomplete_results + + def clear(self): + """Clears the contents of the url bar (via the DELETE shortcut).""" + self.focus('shortcut') + self.urlbar.send_keys(keys.Keys.DELETE) + Wait(self.marionette).until( + lambda _: self.value == '', + message='Contents of location bar could not be cleared.') + + def close_context_menu(self): + """Closes the Location Bar context menu by a key event.""" + # TODO: This method should be implemented via the menu API. + self.contextmenu.send_keys(keys.Keys.ESCAPE) + + @property + def connection_icon(self): + """ Provides access to the urlbar connection icon. + + :returns: Reference to the connection icon element. + """ + return self.marionette.find_element(By.ID, 'connection-icon') + + @property + def contextmenu(self): + """Provides access to the urlbar context menu. + + :returns: Reference to the urlbar context menu. + """ + # TODO: This method should be implemented via the menu API. + parent = self.urlbar.find_element(By.ANON_ATTRIBUTE, {'anonid': 'textbox-input-box'}) + return parent.find_element(By.ANON_ATTRIBUTE, {'anonid': 'input-box-contextmenu'}) + + @property + def focused(self): + """Checks the focus state of the location bar. + + :returns: `True` if focused, otherwise `False` + """ + return self.urlbar.get_attribute('focused') == 'true' + + @property + def identity_icon(self): + """ Provides access to the urlbar identity icon. + + :returns: Reference to the identity icon element. + """ + return self.marionette.find_element(By.ID, 'identity-icon') + + def focus(self, event='click'): + """Focus the location bar according to the provided event. + + :param eventt: The event to synthesize in order to focus the urlbar + (one of `click` or `shortcut`). + """ + if event == 'click': + self.urlbar.click() + elif event == 'shortcut': + cmd_key = self.window.localize_entity('openCmd.commandkey') + self.window.send_shortcut(cmd_key, accel=True) + else: + raise ValueError("An unknown event type was passed: %s" % event) + + Wait(self.marionette).until( + lambda _: self.focused, + message='Location bar has not be focused.') + + def get_contextmenu_entry(self, action): + """Retrieves the urlbar context menu entry corresponding + to the given action. + + :param action: The action corresponding to the retrieved value. + :returns: Reference to the urlbar contextmenu entry. + """ + # TODO: This method should be implemented via the menu API. + entries = self.contextmenu.find_elements(By.CSS_SELECTOR, 'menuitem') + filter_on = 'cmd_%s' % action + found = [e for e in entries if e.get_attribute('cmd') == filter_on] + return found[0] if len(found) else None + + @property + def history_drop_marker(self): + """Provides access to the history drop marker. + + :returns: Reference to the history drop marker. + """ + return self.urlbar.find_element(By.ANON_ATTRIBUTE, {'anonid': 'historydropmarker'}) + + @property + def identity_box(self): + """The DOM element which represents the identity box. + + :returns: Reference to the identity box. + """ + return self.marionette.find_element(By.ID, 'identity-box') + + @property + def identity_country_label(self): + """The DOM element which represents the identity icon country label. + + :returns: Reference to the identity icon country label. + """ + return self.marionette.find_element(By.ID, 'identity-icon-country-label') + + @property + def identity_organization_label(self): + """The DOM element which represents the identity icon label. + + :returns: Reference to the identity icon label. + """ + return self.marionette.find_element(By.ID, 'identity-icon-label') + + @property + def identity_popup(self): + """Provides utility members for accessing and manipulating the + identity popup. + + See the :class:`IdentityPopup` reference. + """ + if not self._identity_popup: + popup = self.marionette.find_element(By.ID, 'identity-popup') + self._identity_popup = IdentityPopup(self.marionette, + self.window, popup) + + return self._identity_popup + + def load_url(self, url): + """Load the specified url in the location bar by synthesized + keystrokes. + + :param url: The url to load. + """ + self.clear() + self.focus('shortcut') + self.urlbar.send_keys(url + keys.Keys.ENTER) + + @property + def notification_popup(self): + """Provides access to the DOM element notification popup. + + :returns: Reference to the notification popup. + """ + return self.marionette.find_element(By.ID, "notification-popup") + + def open_identity_popup(self): + """Open the identity popup.""" + self.identity_box.click() + Wait(self.marionette).until( + lambda _: self.identity_popup.is_open, + message='Identity popup has not been opened.') + + @property + def reload_button(self): + """Provides access to the DOM element reload button. + + :returns: Reference to the reload button. + """ + return self.marionette.find_element(By.ID, 'urlbar-reload-button') + + def reload_url(self, trigger='button', force=False): + """Reload the currently open page. + + :param trigger: The event type to use to cause the reload (one of + `shortcut`, `shortcut2`, or `button`). + :param force: Whether to cause a forced reload. + """ + # TODO: The force parameter is ignored for the moment. Use + # mouse event modifiers or actions when they're ready. + # Bug 1097705 tracks this feature in marionette. + if trigger == 'button': + self.reload_button.click() + elif trigger == 'shortcut': + cmd_key = self.window.localize_entity('reloadCmd.commandkey') + self.window.send_shortcut(cmd_key) + elif trigger == 'shortcut2': + self.window.send_shortcut(keys.Keys.F5) + + @property + def stop_button(self): + """Provides access to the DOM element stop button. + + :returns: Reference to the stop button. + """ + return self.marionette.find_element(By.ID, 'urlbar-stop-button') + + @property + def urlbar(self): + """Provides access to the DOM element urlbar. + + :returns: Reference to the url bar. + """ + return self.marionette.find_element(By.ID, 'urlbar') + + @property + def urlbar_input(self): + """Provides access to the urlbar input element. + + :returns: Reference to the urlbar input. + """ + return self.urlbar.find_element(By.ANON_ATTRIBUTE, {'anonid': 'input'}) + + @property + def value(self): + """Provides access to the currently displayed value of the urlbar. + + :returns: The urlbar value. + """ + return self.urlbar.get_property('value') + + +class AutocompleteResults(UIBaseLib): + """Wraps DOM elements and methods for interacting with autocomplete results.""" + + def close(self, force=False): + """Closes the urlbar autocomplete popup. + + :param force: If true, the popup is closed by its own hide function, + otherwise a key event is sent to close the popup. + """ + if not self.is_open: + return + + if force: + self.marionette.execute_script(""" + arguments[0].hidePopup(); + """, script_args=[self.element]) + else: + self.element.send_keys(keys.Keys.ESCAPE) + + Wait(self.marionette).until( + lambda _: not self.is_open, + message='Autocomplete popup has not been closed.') + + def get_matching_text(self, result, match_type): + """Returns an array of strings of the matching text within an autocomplete + result in the urlbar. + + :param result: The result to inspect for matches. + :param match_type: The type of match to search for (one of `title` or `url`). + """ + + if match_type not in ('title', 'url'): + raise ValueError('match_type provided must be one of' + '"title" or "url", not %s' % match_type) + + # Search for nodes of the given type with emphasized text + emphasized_nodes = result.find_elements( + By.ANON_ATTRIBUTE, + {'class': 'ac-emphasize-text ac-emphasize-text-%s' % match_type} + ) + + return [node.get_property('textContent') for node in emphasized_nodes] + + @property + def visible_results(self): + """Supplies the list of visible autocomplete result nodes. + + :returns: The list of visible results. + """ + match_count = self.element.get_property('_matchCount') + + return self.marionette.execute_script(""" + let rv = []; + let node = arguments[0]; + let count = arguments[1]; + + for (let i = 0; i < count; ++i) { + rv.push(node.getItemAtIndex(i)); + } + + return rv; + """, script_args=[self.results, match_count]) + + @property + def is_open(self): + """Returns whether this popup is currently open. + + :returns: True when the popup is open, otherwise false. + """ + return self.element.get_property('state') == 'open' + + @property + def is_complete(self): + """Returns when this popup is open and autocomplete results are complete. + + :returns: True, when autocomplete results have been populated. + """ + return self.marionette.execute_script(""" + Components.utils.import("resource://gre/modules/Services.jsm"); + + let win = Services.focus.activeWindow; + if (win) { + return win.gURLBar.controller.searchStatus >= + Components.interfaces.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH; + } + + return null; + """) + + @property + def results(self): + """ + :returns: The autocomplete result container node. + """ + return self.element.find_element(By.ANON_ATTRIBUTE, + {'anonid': 'richlistbox'}) + + @property + def selected_index(self): + """Provides the index of the selected item in the autocomplete list. + + :returns: The index. + """ + return self.results.get_property('selectedIndex') + + +class IdentityPopup(UIBaseLib): + """Wraps DOM elements and methods for interacting with the identity popup.""" + + def __init__(self, *args, **kwargs): + super(IdentityPopup, self).__init__(*args, **kwargs) + + self._view = None + + @property + def is_open(self): + """Returns whether this popup is currently open. + + :returns: True when the popup is open, otherwise false. + """ + return self.element.get_property('state') == 'open' + + def close(self, force=False): + """Closes the identity popup by hitting the escape key. + + :param force: Optional, If `True` force close the popup. + Defaults to `False` + """ + if not self.is_open: + return + + if force: + self.marionette.execute_script(""" + arguments[0].hidePopup(); + """, script_args=[self.element]) + else: + self.element.send_keys(keys.Keys.ESCAPE) + + Wait(self.marionette).until( + lambda _: not self.is_open, + message='Identity popup has not been closed.') + + @property + def view(self): + """Provides utility members for accessing and manipulating the + identity popup's multi view. + + See the :class:`IdentityPopupMultiView` reference. + """ + if not self._view: + view = self.marionette.find_element(By.ID, 'identity-popup-multiView') + self._view = IdentityPopupMultiView(self.marionette, self.window, view) + + return self._view + + +class IdentityPopupMultiView(UIBaseLib): + + def _create_view_for_id(self, view_id): + """Creates an instance of :class:`IdentityPopupView` for the specified view id. + + :param view_id: The ID of the view to create an instance of. + + :returns: :class:`IdentityPopupView` instance + """ + mapping = {'identity-popup-mainView': IdentityPopupMainView, + 'identity-popup-securityView': IdentityPopupSecurityView, + } + + view = self.marionette.find_element(By.ID, view_id) + return mapping.get(view_id, IdentityPopupView)(self.marionette, self.window, view) + + @property + def main(self): + """The DOM element which represents the main view. + + :returns: Reference to the main view. + """ + return self._create_view_for_id('identity-popup-mainView') + + @property + def security(self): + """The DOM element which represents the security view. + + :returns: Reference to the security view. + """ + return self._create_view_for_id('identity-popup-securityView') + + +class IdentityPopupView(UIBaseLib): + + @property + def selected(self): + """Checks if the view is selected. + + :return: `True` if the view is selected. + """ + return self.element.get_attribute('current') == 'true' + + +class IdentityPopupMainView(IdentityPopupView): + + @property + def selected(self): + """Checks if the view is selected. + + :return: `True` if the view is selected. + """ + return self.marionette.execute_script(""" + return arguments[0].panelMultiView.getAttribute('viewtype') == 'main'; + """, script_args=[self.element]) + + @property + def expander(self): + """The DOM element which represents the expander button for the security content. + + :returns: Reference to the identity popup expander button. + """ + return self.element.find_element(By.CLASS_NAME, 'identity-popup-expander') + + @property + def host(self): + """The DOM element which represents the identity-popup content host. + + :returns: Reference to the identity-popup content host. + """ + return self.element.find_element(By.CLASS_NAME, 'identity-popup-headline host') + + @property + def insecure_connection_label(self): + """The DOM element which represents the identity popup insecure connection label. + + :returns: Reference to the identity-popup insecure connection label. + """ + return self.element.find_element(By.CLASS_NAME, 'identity-popup-connection-not-secure') + + @property + def internal_connection_label(self): + """The DOM element which represents the identity popup internal connection label. + + :returns: Reference to the identity-popup internal connection label. + """ + return self.element.find_element(By.CSS_SELECTOR, 'description[when-connection=chrome]') + + @property + def permissions(self): + """The DOM element which represents the identity-popup permissions content. + + :returns: Reference to the identity-popup permissions. + """ + return self.element.find_element(By.ID, 'identity-popup-permissions-content') + + @property + def secure_connection_label(self): + """The DOM element which represents the identity popup secure connection label. + + :returns: Reference to the identity-popup secure connection label. + """ + return self.element.find_element(By.CLASS_NAME, 'identity-popup-connection-secure') + + +class IdentityPopupSecurityView(IdentityPopupView): + + @property + def disable_mixed_content_blocking_button(self): + """The DOM element which represents the disable mixed content blocking button. + + :returns: Reference to the disable mixed content blocking button. + """ + return self.element.find_element(By.CSS_SELECTOR, + 'button[when-mixedcontent=active-blocked]') + + @property + def enable_mixed_content_blocking_button(self): + """The DOM element which represents the enable mixed content blocking button. + + :returns: Reference to the enable mixed content blocking button. + """ + return self.element.find_element(By.CSS_SELECTOR, + 'button[when-mixedcontent=active-loaded]') + + @property + def host(self): + """The DOM element which represents the identity-popup content host. + + :returns: Reference to the identity-popup content host. + """ + return self.element.find_element(By.CLASS_NAME, 'identity-popup-headline host') + + @property + def insecure_connection_label(self): + """The DOM element which represents the identity popup insecure connection label. + + :returns: Reference to the identity-popup insecure connection label. + """ + return self.element.find_element(By.CLASS_NAME, 'identity-popup-connection-not-secure') + + @property + def more_info_button(self): + """The DOM element which represents the identity-popup more info button. + + :returns: Reference to the identity-popup more info button. + """ + label = self.window.localize_entity('identity.moreInfoLinkText2') + + return self.element.find_element(By.CSS_SELECTOR, u'button[label="{}"]'.format(label)) + + @property + def owner(self): + """The DOM element which represents the identity-popup content owner. + + :returns: Reference to the identity-popup content owner. + """ + return self.element.find_element(By.ID, 'identity-popup-content-owner') + + @property + def owner_location(self): + """The DOM element which represents the identity-popup content supplemental. + + :returns: Reference to the identity-popup content supplemental. + """ + return self.element.find_element(By.ID, 'identity-popup-content-supplemental') + + @property + def secure_connection_label(self): + """The DOM element which represents the identity popup secure connection label. + + :returns: Reference to the identity-popup secure connection label. + """ + return self.element.find_element(By.CLASS_NAME, 'identity-popup-connection-secure') + + @property + def verifier(self): + """The DOM element which represents the identity-popup content verifier. + + :returns: Reference to the identity-popup content verifier. + """ + return self.element.find_element(By.ID, 'identity-popup-content-verifier') diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/browser/window.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/browser/window.py new file mode 100644 index 000000000..728a2fd20 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/browser/window.py @@ -0,0 +1,260 @@ +# 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, Wait +from marionette_driver.errors import NoSuchElementException + +from firefox_puppeteer.ui.about_window.window import AboutWindow +from firefox_puppeteer.ui.browser.notifications import ( + AddOnInstallBlockedNotification, + AddOnInstallConfirmationNotification, + AddOnInstallCompleteNotification, + AddOnInstallFailedNotification, + AddOnProgressNotification, + BaseNotification) +from firefox_puppeteer.ui.browser.tabbar import TabBar +from firefox_puppeteer.ui.browser.toolbars import NavBar +from firefox_puppeteer.ui.pageinfo.window import PageInfoWindow +from firefox_puppeteer.ui.windows import BaseWindow, Windows + + +class BrowserWindow(BaseWindow): + """Representation of a browser window.""" + + window_type = 'navigator:browser' + + dtds = [ + 'chrome://branding/locale/brand.dtd', + 'chrome://browser/locale/aboutPrivateBrowsing.dtd', + 'chrome://browser/locale/browser.dtd', + 'chrome://browser/locale/netError.dtd', + ] + + properties = [ + 'chrome://branding/locale/brand.properties', + 'chrome://branding/locale/browserconfig.properties', + 'chrome://browser/locale/browser.properties', + 'chrome://browser/locale/preferences/preferences.properties', + 'chrome://global/locale/browser.properties', + ] + + def __init__(self, *args, **kwargs): + super(BrowserWindow, self).__init__(*args, **kwargs) + + self._navbar = None + self._tabbar = None + + @property + def default_homepage(self): + """The default homepage as used by the current locale. + + :returns: The default homepage for the current locale. + """ + return self.marionette.get_pref('browser.startup.homepage', + value_type='nsIPrefLocalizedString') + + @property + def is_private(self): + """Returns True if this is a Private Browsing window.""" + self.switch_to() + + with self.marionette.using_context('chrome'): + return self.marionette.execute_script(""" + Components.utils.import("resource://gre/modules/PrivateBrowsingUtils.jsm"); + + let chromeWindow = arguments[0].ownerDocument.defaultView; + return PrivateBrowsingUtils.isWindowPrivate(chromeWindow); + """, script_args=[self.window_element]) + + @property + def navbar(self): + """Provides access to the navigation bar. This is the toolbar containing + the back, forward and home buttons. It also contains the location bar. + + See the :class:`~ui.browser.toolbars.NavBar` reference. + """ + self.switch_to() + + if not self._navbar: + navbar = self.window_element.find_element(By.ID, 'nav-bar') + self._navbar = NavBar(self.marionette, self, navbar) + + return self._navbar + + @property + def notification(self): + """Provides access to the currently displayed notification.""" + + notifications_map = { + 'addon-install-blocked-notification': AddOnInstallBlockedNotification, + 'addon-install-confirmation-notification': AddOnInstallConfirmationNotification, + 'addon-install-complete-notification': AddOnInstallCompleteNotification, + 'addon-install-failed-notification': AddOnInstallFailedNotification, + 'addon-progress-notification': AddOnProgressNotification, + } + + try: + notification = self.window_element.find_element( + By.CSS_SELECTOR, '#notification-popup popupnotification') + + notification_id = notification.get_attribute('id') + return notifications_map.get(notification_id, BaseNotification)( + self.marionette, self, notification) + + except NoSuchElementException: + return None # no notification is displayed + + def wait_for_notification(self, notification_class=BaseNotification, + timeout=5): + """Waits for the specified notification to be displayed. + + :param notification_class: Optional, the notification class to wait for. + If `None` is specified it will wait for any notification to be closed. + Defaults to `BaseNotification`. + :param timeout: Optional, how long to wait for the expected notification. + Defaults to 5 seconds. + """ + wait = Wait(self.marionette, timeout=timeout) + + if notification_class: + if notification_class is BaseNotification: + message = 'No notification was shown.' + else: + message = '{0} was not shown.'.format(notification_class.__name__) + wait.until( + lambda _: isinstance(self.notification, notification_class), + message=message) + else: + message = 'Unexpected notification shown.' + wait.until( + lambda _: self.notification is None, + message='Unexpected notification shown.') + + @property + def tabbar(self): + """Provides access to the tab bar. + + See the :class:`~ui.browser.tabbar.TabBar` reference. + """ + self.switch_to() + + if not self._tabbar: + tabbrowser = self.window_element.find_element(By.ID, 'tabbrowser-tabs') + self._tabbar = TabBar(self.marionette, self, tabbrowser) + + return self._tabbar + + def close(self, trigger='menu', force=False): + """Closes the current browser window by using the specified trigger. + + :param trigger: Optional, method to close the current browser window. This can + be a string with one of `menu` or `shortcut`, or a callback which gets triggered + with the current :class:`BrowserWindow` as parameter. Defaults to `menu`. + + :param force: Optional, forces the closing of the window by using the Gecko API. + Defaults to `False`. + """ + def callback(win): + # Prepare action which triggers the opening of the browser window + if callable(trigger): + trigger(win) + elif trigger == 'menu': + self.menubar.select_by_id('file-menu', 'menu_closeWindow') + elif trigger == 'shortcut': + win.send_shortcut(win.localize_entity('closeCmd.key'), + accel=True, shift=True) + else: + raise ValueError('Unknown closing method: "%s"' % trigger) + + BaseWindow.close(self, callback, force) + + def get_final_url(self, url): + """Loads the page at `url` and returns the resulting url. + + This function enables testing redirects. + + :param url: The url to test. + :returns: The resulting loaded url. + """ + with self.marionette.using_context('content'): + self.marionette.navigate(url) + return self.marionette.get_url() + + def open_browser(self, trigger='menu', is_private=False): + """Opens a new browser window by using the specified trigger. + + :param trigger: Optional, method in how to open the new browser window. This can + be a string with one of `menu` or `shortcut`, or a callback which gets triggered + with the current :class:`BrowserWindow` as parameter. Defaults to `menu`. + + :param is_private: Optional, if True the new window will be a private browsing one. + + :returns: :class:`BrowserWindow` instance for the new browser window. + """ + def callback(win): + # Prepare action which triggers the opening of the browser window + if callable(trigger): + trigger(win) + elif trigger == 'menu': + menu_id = 'menu_newPrivateWindow' if is_private else 'menu_newNavigator' + self.menubar.select_by_id('file-menu', menu_id) + elif trigger == 'shortcut': + cmd_key = 'privateBrowsingCmd.commandkey' if is_private else 'newNavigatorCmd.key' + win.send_shortcut(win.localize_entity(cmd_key), + accel=True, shift=is_private) + else: + raise ValueError('Unknown opening method: "%s"' % trigger) + + return BaseWindow.open_window(self, callback, BrowserWindow) + + def open_about_window(self, trigger='menu'): + """Opens the about window by using the specified trigger. + + :param trigger: Optional, method in how to open the new browser window. This can + either the string `menu` or a callback which gets triggered + with the current :class:`BrowserWindow` as parameter. Defaults to `menu`. + + :returns: :class:`AboutWindow` instance of the opened window. + """ + def callback(win): + # Prepare action which triggers the opening of the browser window + if callable(trigger): + trigger(win) + elif trigger == 'menu': + self.menubar.select_by_id('helpMenu', 'aboutName') + else: + raise ValueError('Unknown opening method: "%s"' % trigger) + + return BaseWindow.open_window(self, callback, AboutWindow) + + def open_page_info_window(self, trigger='menu'): + """Opens the page info window by using the specified trigger. + + :param trigger: Optional, method in how to open the new browser window. This can + be a string with one of `menu` or `shortcut`, or a callback which gets triggered + with the current :class:`BrowserWindow` as parameter. Defaults to `menu`. + + :returns: :class:`PageInfoWindow` instance of the opened window. + """ + def callback(win): + # Prepare action which triggers the opening of the browser window + if callable(trigger): + trigger(win) + elif trigger == 'menu': + self.menubar.select_by_id('tools-menu', 'menu_pageInfo') + elif trigger == 'shortcut': + if win.marionette.session_capabilities['platformName'] == 'windows_nt': + raise ValueError('Page info shortcut not available on Windows.') + win.send_shortcut(win.localize_entity('pageInfoCmd.commandkey'), + accel=True) + elif trigger == 'context_menu': + # TODO: Add once we can do right clicks + pass + else: + raise ValueError('Unknown opening method: "%s"' % trigger) + + return BaseWindow.open_window(self, callback, PageInfoWindow) + + +Windows.register_window(BrowserWindow.window_type, BrowserWindow) diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/deck.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/deck.py new file mode 100644 index 000000000..acc6d2458 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/deck.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 firefox_puppeteer.ui.base import UIBaseLib + + +class Panel(UIBaseLib): + + def __eq__(self, other): + return self.element.get_attribute('id') == other.element.get_attribute('id') + + def __ne__(self, other): + return self.element.get_attribute('id') != other.element.get_attribute('id') + + def __str__(self): + return self.element.get_attribute('id') diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/menu.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/menu.py new file mode 100644 index 000000000..8251daa01 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/menu.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/. + +from marionette_driver import By +from marionette_driver.errors import NoSuchElementException + +from firefox_puppeteer.base import BaseLib +from firefox_puppeteer.ui.base import DOMElement + + +class MenuBar(BaseLib): + """Wraps the menubar DOM element inside a browser window.""" + + @property + def menus(self): + """A list of :class:`MenuElement` instances corresponding to + the top level menus in the menubar. + + :returns: A list of :class:`MenuElement` instances. + """ + menus = (self.marionette.find_element(By.ID, 'main-menubar') + .find_elements(By.TAG_NAME, 'menu')) + return [self.MenuElement(menu) for menu in menus] + + def get_menu(self, label): + """Get a :class:`MenuElement` instance corresponding to the specified label. + + :param label: The label of the menu, e.g., **File** or **View**. + :returns: A :class:`MenuElement` instance. + """ + menu = [m for m in self.menus if m.get_attribute('label') == label] + + if not menu: + raise NoSuchElementException('Could not find a menu with ' + 'label "{}"'.format(label)) + + return menu[0] + + def get_menu_by_id(self, menu_id): + """Get a :class:`MenuElement` instance corresponding to the specified + ID. + + :param menu_id: The ID of the menu, e.g., **file-menu** or **view-menu**. + :returns: A :class:`MenuElement` instance. + """ + menu = [m for m in self.menus if m.get_attribute('id') == menu_id] + + if not menu: + raise NoSuchElementException('Could not find a menu with ' + 'id "{}"'.format(menu_id)) + + return menu[0] + + def select(self, label, item): + """Select an item in a menu. + + :param label: The label of the menu, e.g., **File** or **View**. + :param item: The label of the item in the menu, e.g., **New Tab**. + """ + return self.get_menu(label).select(item) + + def select_by_id(self, menu_id, item_id): + """Select an item in a menu. + + :param menu_id: The ID of the menu, e.g. **file-menu** or **view-menu**. + :param item_id: The ID of the item in the menu, e.g. **menu_newNavigatorTab**. + """ + return self.get_menu_by_id(menu_id).select_by_id(item_id) + + class MenuElement(DOMElement): + """Wraps a menu element DOM element.""" + + @property + def items(self): + """A list of menuitem DOM elements within this :class:`MenuElement` instance. + + :returns: A list of items in the menu. + """ + return (self.find_element(By.TAG_NAME, 'menupopup') + .find_elements(By.TAG_NAME, 'menuitem')) + + def select(self, label): + """Click on a menu item within this menu. + + :param label: The label of the menu item, e.g., **New Tab**. + """ + item = [l for l in self.items if l.get_attribute('label') == label] + + if not item: + message = ("Item labeled '{}' not found in the '{}' menu" + .format(label, self.get_attribute('label'))) + raise NoSuchElementException(message) + + return item[0].click() + + def select_by_id(self, menu_item_id): + """Click on a menu item within this menu. + + :param menu_item_id: The ID of the menu item, e.g. **menu_newNavigatorTab**. + """ + item = [l for l in self.items if l.get_attribute('id') == + menu_item_id] + + if not item: + message = ("Item with ID '{}' not found in the '{}' menu" + .format(menu_item_id, self.get_attribute('id'))) + raise NoSuchElementException(message) + + return item[0].click() diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/pageinfo/__init__.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/pageinfo/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/pageinfo/__init__.py diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/pageinfo/deck.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/pageinfo/deck.py new file mode 100644 index 000000000..0f2a2167a --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/pageinfo/deck.py @@ -0,0 +1,204 @@ +# 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, Wait + +from firefox_puppeteer.ui.base import UIBaseLib +from firefox_puppeteer.ui.deck import Panel + + +class Deck(UIBaseLib): + + def _create_panel_for_id(self, panel_id): + """Creates an instance of :class:`Panel` for the specified panel id. + + :param panel_id: The ID of the panel to create an instance of. + + :returns: :class:`Panel` instance + """ + mapping = {'feedPanel': FeedPanel, + 'generalPanel': GeneralPanel, + 'mediaPanel': MediaPanel, + 'permPanel': PermissionsPanel, + 'securityPanel': SecurityPanel + } + + panel = self.element.find_element(By.ID, panel_id) + return mapping.get(panel_id, Panel)(self.marionette, self.window, panel) + + # Properties for visual elements of the deck # + + @property + def feed(self): + """The :class:`FeedPanel` instance for the feed panel. + + :returns: :class:`FeedPanel` instance. + """ + return self._create_panel_for_id('feedPanel') + + @property + def general(self): + """The :class:`GeneralPanel` instance for the general panel. + + :returns: :class:`GeneralPanel` instance. + """ + return self._create_panel_for_id('generalPanel') + + @property + def media(self): + """The :class:`MediaPanel` instance for the media panel. + + :returns: :class:`MediaPanel` instance. + """ + return self._create_panel_for_id('mediaPanel') + + @property + def panels(self): + """List of all the :class:`Panel` instances of the current deck. + + :returns: List of :class:`Panel` instances. + """ + panels = self.marionette.execute_script(""" + let deck = arguments[0]; + let panels = []; + + for (let index = 0; index < deck.children.length; index++) { + if (deck.children[index].id) { + panels.push(deck.children[index].id); + } + } + + return panels; + """, script_args=[self.element]) + + return [self._create_panel_for_id(panel) for panel in panels] + + @property + def permissions(self): + """The :class:`PermissionsPanel` instance for the permissions panel. + + :returns: :class:`PermissionsPanel` instance. + """ + return self._create_panel_for_id('permPanel') + + @property + def security(self): + """The :class:`SecurityPanel` instance for the security panel. + + :returns: :class:`SecurityPanel` instance. + """ + return self._create_panel_for_id('securityPanel') + + # Properties for helpers when working with the deck # + + @property + def selected_index(self): + """The index of the currently selected panel. + + :return: Index of the selected panel. + """ + return int(self.element.get_property('selectedIndex')) + + @property + def selected_panel(self): + """A :class:`Panel` instance of the currently selected panel. + + :returns: :class:`Panel` instance. + """ + return self.panels[self.selected_index] + + # Methods for helpers when working with the deck # + + def select(self, panel): + """Selects the specified panel via the tab element. + + :param panel: The panel to select. + + :returns: :class:`Panel` instance of the selected panel. + """ + panel.tab.click() + Wait(self.marionette).until( + lambda _: self.selected_panel == panel, + message='Panel with ID "%s" could not be selected.' % panel) + + return panel + + +class PageInfoPanel(Panel): + + @property + def tab(self): + """The DOM element which represents the corresponding tab element at the top. + + :returns: Reference to the tab element. + """ + name = self.element.get_property('id').split('Panel')[0] + return self.window.window_element.find_element(By.ID, name + 'Tab') + + +class FeedPanel(PageInfoPanel): + pass + + +class GeneralPanel(PageInfoPanel): + pass + + +class MediaPanel(PageInfoPanel): + pass + + +class PermissionsPanel(PageInfoPanel): + pass + + +class SecurityPanel(PageInfoPanel): + + @property + def domain(self): + """The DOM element which represents the domain textbox. + + :returns: Reference to the textbox element. + """ + return self.element.find_element(By.ID, 'security-identity-domain-value') + + @property + def owner(self): + """The DOM element which represents the owner textbox. + + :returns: Reference to the textbox element. + """ + return self.element.find_element(By.ID, 'security-identity-owner-value') + + @property + def verifier(self): + """The DOM element which represents the verifier textbox. + + :returns: Reference to the textbox element. + """ + return self.element.find_element(By.ID, 'security-identity-verifier-value') + + @property + def view_certificate(self): + """The DOM element which represents the view certificate button. + + :returns: Reference to the button element. + """ + return self.element.find_element(By.ID, 'security-view-cert') + + @property + def view_cookies(self): + """The DOM element which represents the view cookies button. + + :returns: Reference to the button element. + """ + return self.element.find_element(By.ID, 'security-view-cookies') + + @property + def view_passwords(self): + """The DOM element which represents the view passwords button. + + :returns: Reference to the button element. + """ + return self.element.find_element(By.ID, 'security-view-password') diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/pageinfo/window.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/pageinfo/window.py new file mode 100644 index 000000000..070f39f79 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/pageinfo/window.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 firefox_puppeteer.ui.pageinfo.deck import Deck +from firefox_puppeteer.ui.windows import BaseWindow, Windows + + +class PageInfoWindow(BaseWindow): + """Representation of a page info window.""" + + window_type = 'Browser:page-info' + + dtds = [ + 'chrome://browser/locale/pageInfo.dtd', + ] + + properties = [ + 'chrome://browser/locale/browser.properties', + 'chrome://browser/locale/pageInfo.properties', + 'chrome://pippki/locale/pippki.properties', + ] + + @property + def deck(self): + """The :class:`Deck` instance which represents the deck. + + :returns: Reference to the deck. + """ + deck = self.window_element.find_element(By.ID, 'mainDeck') + return Deck(self.marionette, self, deck) + + def close(self, trigger='shortcut', force=False): + """Closes the current page info window by using the specified trigger. + + :param trigger: Optional, method to close the current window. This can + be a string with one of `menu` (OS X only) or `shortcut`, or a callback + which gets triggered with the current :class:`PageInfoWindow` as parameter. + Defaults to `shortcut`. + + :param force: Optional, forces the closing of the window by using the Gecko API. + Defaults to `False`. + """ + def callback(win): + # Prepare action which triggers the opening of the browser window + if callable(trigger): + trigger(win) + elif trigger == 'menu': + self.menubar.select_by_id('file-menu', 'menu_close') + elif trigger == 'shortcut': + win.send_shortcut(win.localize_entity('closeWindow.key'), + accel=True) + else: + raise ValueError('Unknown closing method: "%s"' % trigger) + + super(PageInfoWindow, self).close(callback, force) + + +Windows.register_window(PageInfoWindow.window_type, PageInfoWindow) diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/update_wizard/__init__.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/update_wizard/__init__.py new file mode 100644 index 000000000..020996694 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/update_wizard/__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 dialog import UpdateWizardDialog diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/update_wizard/dialog.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/update_wizard/dialog.py new file mode 100644 index 000000000..19435b211 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/update_wizard/dialog.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 import By, Wait + +from firefox_puppeteer.ui.update_wizard.wizard import Wizard +from firefox_puppeteer.ui.windows import BaseWindow, Windows + + +# Bug 1143020 - Subclass from BaseDialog ui class with possible wizard mixin +class UpdateWizardDialog(BaseWindow): + """Representation of the old Software Update Wizard Dialog.""" + window_type = 'Update:Wizard' + + dtds = [ + 'chrome://branding/locale/brand.dtd', + 'chrome://mozapps/locale/update/updates.dtd', + ] + + properties = [ + 'chrome://branding/locale/brand.properties', + 'chrome://mozapps/locale/update/updates.properties', + ] + + @property + def wizard(self): + """The :class:`Wizard` instance which represents the wizard. + + :returns: Reference to the wizard. + """ + # The deck is also the root element + wizard = self.marionette.find_element(By.ID, 'updates') + return Wizard(self.marionette, self, wizard) + + def select_next_page(self): + """Clicks on "Next" button, and waits for the next page to show up.""" + current_panel = self.wizard.selected_panel + + self.wizard.next_button.click() + Wait(self.marionette).until( + lambda _: self.wizard.selected_panel != current_panel, + message='Next panel has not been selected.') + + +Windows.register_window(UpdateWizardDialog.window_type, UpdateWizardDialog) diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/update_wizard/wizard.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/update_wizard/wizard.py new file mode 100644 index 000000000..9687ac917 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/update_wizard/wizard.py @@ -0,0 +1,291 @@ +# 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, Wait + +from firefox_puppeteer.ui.base import UIBaseLib +from firefox_puppeteer.ui.deck import Panel + + +class Wizard(UIBaseLib): + + def __init__(self, *args, **kwargs): + super(Wizard, self).__init__(*args, **kwargs) + + Wait(self.marionette).until( + lambda _: self.selected_panel, + message='No panel has been selected by default.') + + def _create_panel_for_id(self, panel_id): + """Creates an instance of :class:`Panel` for the specified panel id. + + :param panel_id: The ID of the panel to create an instance of. + + :returns: :class:`Panel` instance + """ + mapping = {'checking': CheckingPanel, + 'downloading': DownloadingPanel, + 'dummy': DummyPanel, + 'errorpatching': ErrorPatchingPanel, + 'errors': ErrorPanel, + 'errorextra': ErrorExtraPanel, + 'finished': FinishedPanel, + 'finishedBackground': FinishedBackgroundPanel, + 'manualUpdate': ManualUpdatePanel, + 'noupdatesfound': NoUpdatesFoundPanel, + 'updatesfoundbasic': UpdatesFoundBasicPanel, + + # TODO: Remove once we no longer support version Firefox 45.0ESR + 'incompatibleCheck': IncompatibleCheckPanel, + 'incompatibleList': IncompatibleListPanel, + } + + panel = self.element.find_element(By.ID, panel_id) + return mapping.get(panel_id, Panel)(self.marionette, self.window, panel) + + # Properties for visual buttons of the wizard # + + @property + def _buttons(self): + return self.element.find_element(By.ANON_ATTRIBUTE, {'anonid': 'Buttons'}) + + @property + def cancel_button(self): + return self._buttons.find_element(By.ANON_ATTRIBUTE, {'dlgtype': 'cancel'}) + + @property + def extra1_button(self): + return self._buttons.find_element(By.ANON_ATTRIBUTE, {'dlgtype': 'extra1'}) + + @property + def extra2_button(self): + return self._buttons.find_element(By.ANON_ATTRIBUTE, {'dlgtype': 'extra2'}) + + @property + def previous_button(self): + return self._buttons.find_element(By.ANON_ATTRIBUTE, {'dlgtype': 'back'}) + + @property + def finish_button(self): + return self._buttons.find_element(By.ANON_ATTRIBUTE, {'dlgtype': 'finish'}) + + @property + def next_button(self): + return self._buttons.find_element(By.ANON_ATTRIBUTE, {'dlgtype': 'next'}) + + # Properties for visual panels of the wizard # + + @property + def checking(self): + """The checking for updates panel. + + :returns: :class:`CheckingPanel` instance. + """ + return self._create_panel_for_id('checking') + + @property + def downloading(self): + """The downloading panel. + + :returns: :class:`DownloadingPanel` instance. + """ + return self._create_panel_for_id('downloading') + + @property + def dummy(self): + """The dummy panel. + + :returns: :class:`DummyPanel` instance. + """ + return self._create_panel_for_id('dummy') + + @property + def error_patching(self): + """The error patching panel. + + :returns: :class:`ErrorPatchingPanel` instance. + """ + return self._create_panel_for_id('errorpatching') + + @property + def error(self): + """The errors panel. + + :returns: :class:`ErrorPanel` instance. + """ + return self._create_panel_for_id('errors') + + @property + def error_extra(self): + """The error extra panel. + + :returns: :class:`ErrorExtraPanel` instance. + """ + return self._create_panel_for_id('errorextra') + + @property + def finished(self): + """The finished panel. + + :returns: :class:`FinishedPanel` instance. + """ + return self._create_panel_for_id('finished') + + @property + def finished_background(self): + """The finished background panel. + + :returns: :class:`FinishedBackgroundPanel` instance. + """ + return self._create_panel_for_id('finishedBackground') + + @property + def incompatible_check(self): + """The incompatible check panel. + + :returns: :class:`IncompatibleCheckPanel` instance. + """ + return self._create_panel_for_id('incompatibleCheck') + + @property + def incompatible_list(self): + """The incompatible list panel. + + :returns: :class:`IncompatibleListPanel` instance. + """ + return self._create_panel_for_id('incompatibleList') + + @property + def manual_update(self): + """The manual update panel. + + :returns: :class:`ManualUpdatePanel` instance. + """ + return self._create_panel_for_id('manualUpdate') + + @property + def no_updates_found(self): + """The no updates found panel. + + :returns: :class:`NoUpdatesFoundPanel` instance. + """ + return self._create_panel_for_id('noupdatesfound') + + @property + def updates_found_basic(self): + """The updates found panel. + + :returns: :class:`UpdatesFoundPanel` instance. + """ + return self._create_panel_for_id('updatesfoundbasic') + + @property + def panels(self): + """List of all the available :class:`Panel` instances. + + :returns: List of :class:`Panel` instances. + """ + panels = self.marionette.execute_script(""" + let wizard = arguments[0]; + let panels = []; + + for (let index = 0; index < wizard.children.length; index++) { + if (wizard.children[index].id) { + panels.push(wizard.children[index].id); + } + } + + return panels; + """, script_args=[self.element]) + + return [self._create_panel_for_id(panel) for panel in panels] + + @property + def selected_index(self): + """The index of the currently selected panel. + + :return: Index of the selected panel. + """ + return int(self.element.get_property('pageIndex')) + + @property + def selected_panel(self): + """A :class:`Panel` instance of the currently selected panel. + + :returns: :class:`Panel` instance. + """ + return self._create_panel_for_id(self.element.get_attribute('currentpageid')) + + +class CheckingPanel(Panel): + + @property + def progress(self): + """The DOM element which represents the progress meter. + + :returns: Reference to the progress element. + """ + return self.element.find_element(By.ID, 'checkingProgress') + + +class DownloadingPanel(Panel): + + @property + def progress(self): + """The DOM element which represents the progress meter. + + :returns: Reference to the progress element. + """ + return self.element.find_element(By.ID, 'downloadProgress') + + +class DummyPanel(Panel): + pass + + +class ErrorPatchingPanel(Panel): + pass + + +class ErrorPanel(Panel): + pass + + +class ErrorExtraPanel(Panel): + pass + + +class FinishedPanel(Panel): + pass + + +class FinishedBackgroundPanel(Panel): + pass + + +class IncompatibleCheckPanel(Panel): + + @property + def progress(self): + """The DOM element which represents the progress meter. + + :returns: Reference to the progress element. + """ + return self.element.find_element(By.ID, 'incompatibleCheckProgress') + + +class IncompatibleListPanel(Panel): + pass + + +class ManualUpdatePanel(Panel): + pass + + +class NoUpdatesFoundPanel(Panel): + pass + + +class UpdatesFoundBasicPanel(Panel): + pass diff --git a/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/windows.py b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/windows.py new file mode 100644 index 000000000..9248a3935 --- /dev/null +++ b/testing/marionette/puppeteer/firefox/firefox_puppeteer/ui/windows.py @@ -0,0 +1,435 @@ +# 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, Wait +from marionette_driver.errors import NoSuchWindowException +from marionette_driver.keys import Keys + +import firefox_puppeteer.errors as errors + +from firefox_puppeteer.api.l10n import L10n +from firefox_puppeteer.base import BaseLib +from firefox_puppeteer.decorators import use_class_as_property + + +class Windows(BaseLib): + + # Used for registering the different windows with this class to avoid + # circular dependencies with BaseWindow + windows_map = {} + + @property + def all(self): + """Retrieves a list of all open chrome windows. + + :returns: List of :class:`BaseWindow` instances corresponding to the + windows in `marionette.chrome_window_handles`. + """ + return [self.create_window_instance(handle) for handle in + self.marionette.chrome_window_handles] + + @property + def current(self): + """Retrieves the currently selected chrome window. + + :returns: The :class:`BaseWindow` for the currently active window. + """ + return self.create_window_instance(self.marionette.current_chrome_window_handle) + + @property + def focused_chrome_window_handle(self): + """Returns the currently focused chrome window handle. + + :returns: The `window handle` of the focused chrome window. + """ + def get_active_handle(mn): + with self.marionette.using_context('chrome'): + return self.marionette.execute_script(""" + Components.utils.import("resource://gre/modules/Services.jsm"); + + let win = Services.focus.activeWindow; + if (win) { + return win.QueryInterface(Components.interfaces.nsIInterfaceRequestor) + .getInterface(Components.interfaces.nsIDOMWindowUtils) + .outerWindowID.toString(); + } + + return null; + """) + + # In case of `None` being returned no window is currently active. This can happen + # when a focus change action is currently happening. So lets wait until it is done. + return Wait(self.marionette).until(get_active_handle, + message='No focused window has been found.') + + def close(self, handle): + """Closes the chrome window with the given handle. + + :param handle: The handle of the chrome window. + """ + self.switch_to(handle) + + # TODO: Maybe needs to wait as handled via an observer + return self.marionette.close_chrome_window() + + def close_all(self, exceptions=None): + """Closes all open chrome windows. + + There is an optional `exceptions` list, which can be used to exclude + specific chrome windows from being closed. + + :param exceptions: Optional, list of :class:`BaseWindow` instances not to close. + """ + windows_to_keep = exceptions or [] + + # Get handles of windows_to_keep + handles_to_keep = [entry.handle for entry in windows_to_keep] + + # Find handles to close and close them all + handles_to_close = set(self.marionette.chrome_window_handles) - set(handles_to_keep) + for handle in handles_to_close: + self.close(handle) + + def create_window_instance(self, handle, expected_class=None): + """Creates a :class:`BaseWindow` instance for the given chrome window. + + :param handle: The handle of the chrome window. + :param expected_class: Optional, check for the correct window class. + """ + current_handle = self.marionette.current_chrome_window_handle + window = None + + with self.marionette.using_context('chrome'): + try: + # Retrieve window type to determine the type of chrome window + if handle != self.marionette.current_chrome_window_handle: + self.switch_to(handle) + window_type = self.marionette.get_window_type() + finally: + # Ensure to switch back to the original window + if handle != current_handle: + self.switch_to(current_handle) + + if window_type in self.windows_map: + window = self.windows_map[window_type](self.marionette, handle) + else: + window = BaseWindow(self.marionette, handle) + + if expected_class is not None and type(window) is not expected_class: + raise errors.UnexpectedWindowTypeError('Expected window "%s" but got "%s"' % + (expected_class, type(window))) + + # Before continuing ensure the chrome window has been completed loading + Wait(self.marionette).until( + lambda _: self.loaded(handle), + message='Chrome window with handle "%s" did not finish loading.' % handle) + + return window + + def focus(self, handle): + """Focuses the chrome window with the given handle. + + :param handle: The handle of the chrome window. + """ + self.switch_to(handle) + + with self.marionette.using_context('chrome'): + self.marionette.execute_script(""" window.focus(); """) + + Wait(self.marionette).until( + lambda _: handle == self.focused_chrome_window_handle, + message='Focus has not been set to chrome window handle "%s".' % handle) + + def loaded(self, handle): + """Check if the chrome window with the given handle has been completed loading. + + :param handle: The handle of the chrome window. + + :returns: True, if the chrome window has been loaded. + """ + with self.marionette.using_context('chrome'): + return self.marionette.execute_script(""" + Components.utils.import("resource://gre/modules/Services.jsm"); + + let win = Services.wm.getOuterWindowWithId(Number(arguments[0])); + return win.document.readyState == 'complete'; + """, script_args=[handle]) + + def switch_to(self, target): + """Switches context to the specified chrome window. + + :param target: The window to switch to. `target` can be a `handle` or a + callback that returns True in the context of the desired + window. + + :returns: Instance of the selected :class:`BaseWindow`. + """ + target_handle = None + + if target in self.marionette.chrome_window_handles: + target_handle = target + elif callable(target): + current_handle = self.marionette.current_chrome_window_handle + + # switches context if callback for a chrome window returns `True`. + for handle in self.marionette.chrome_window_handles: + self.marionette.switch_to_window(handle) + window = self.create_window_instance(handle) + if target(window): + target_handle = handle + break + + # if no handle has been found switch back to original window + if not target_handle: + self.marionette.switch_to_window(current_handle) + + if target_handle is None: + raise NoSuchWindowException("No window found for '{}'" + .format(target)) + + # only switch if necessary + if target_handle != self.marionette.current_chrome_window_handle: + self.marionette.switch_to_window(target_handle) + + return self.create_window_instance(target_handle) + + @classmethod + def register_window(cls, window_type, window_class): + """Registers a chrome window with this class so that this class may in + turn create the appropriate window instance later on. + + :param window_type: The type of window. + :param window_class: The constructor of the window + """ + cls.windows_map[window_type] = window_class + + +class BaseWindow(BaseLib): + """Base class for any kind of chrome window.""" + + # l10n class attributes will be set by each window class individually + dtds = [] + properties = [] + + def __init__(self, marionette, window_handle): + super(BaseWindow, self).__init__(marionette) + + self._l10n = L10n(self.marionette) + self._windows = Windows(self.marionette) + + if window_handle not in self.marionette.chrome_window_handles: + raise errors.UnknownWindowError('Window with handle "%s" does not exist' % + window_handle) + self._handle = window_handle + + def __eq__(self, other): + return self.handle == other.handle + + @property + def closed(self): + """Returns closed state of the chrome window. + + :returns: True if the window has been closed. + """ + return self.handle not in self.marionette.chrome_window_handles + + @property + def focused(self): + """Returns `True` if the chrome window is focused. + + :returns: True if the window is focused. + """ + self.switch_to() + + return self.handle == self._windows.focused_chrome_window_handle + + @property + def handle(self): + """Returns the `window handle` of the chrome window. + + :returns: `window handle`. + """ + return self._handle + + @property + def loaded(self): + """Checks if the window has been fully loaded. + + :returns: True, if the window is loaded. + """ + self._windows.loaded(self.handle) + + @use_class_as_property('ui.menu.MenuBar') + def menubar(self): + """Provides access to the menu bar, for example, the **File** menu. + + See the :class:`~ui.menu.MenuBar` reference. + """ + + @property + def window_element(self): + """Returns the inner DOM window element. + + :returns: DOM window element. + """ + self.switch_to() + + return self.marionette.find_element(By.CSS_SELECTOR, ':root') + + def close(self, callback=None, force=False): + """Closes the current chrome window. + + If this is the last remaining window, the Marionette session is ended. + + :param callback: Optional, function to trigger the window to open. It is + triggered with the current :class:`BaseWindow` as parameter. + Defaults to `window.open()`. + + :param force: Optional, forces the closing of the window by using the Gecko API. + Defaults to `False`. + """ + self.switch_to() + + # Bug 1121698 + # For more stable tests register an observer topic first + prev_win_count = len(self.marionette.chrome_window_handles) + + handle = self.handle + if force or callback is None: + self._windows.close(handle) + else: + callback(self) + + # Bug 1121698 + # Observer code should let us ditch this wait code + Wait(self.marionette).until( + lambda m: len(m.chrome_window_handles) == prev_win_count - 1, + message='Chrome window with handle "%s" has not been closed.' % handle) + + def focus(self): + """Sets the focus to the current chrome window.""" + return self._windows.focus(self.handle) + + def localize_entity(self, entity_id): + """Returns the localized string for the specified DTD entity id. + + :param entity_id: The id to retrieve the value from. + + :returns: The localized string for the requested entity. + + :raises MarionetteException: When entity id is not found. + """ + return self._l10n.localize_entity(self.dtds, entity_id) + + def localize_property(self, property_id): + """Returns the localized string for the specified property id. + + :param property_id: The id to retrieve the value from. + + :returns: The localized string for the requested property. + + :raises MarionetteException: When property id is not found. + """ + return self._l10n.localize_property(self.properties, property_id) + + def open_window(self, callback=None, expected_window_class=None, focus=True): + """Opens a new top-level chrome window. + + :param callback: Optional, function to trigger the window to open. It is + triggered with the current :class:`BaseWindow` as parameter. + Defaults to `window.open()`. + :param expected_class: Optional, check for the correct window class. + :param focus: Optional, if true, focus the new window. + Defaults to `True`. + """ + # Bug 1121698 + # For more stable tests register an observer topic first + start_handles = self.marionette.chrome_window_handles + + self.switch_to() + with self.marionette.using_context('chrome'): + if callback is not None: + callback(self) + else: + self.marionette.execute_script(""" window.open(); """) + + # TODO: Needs to be replaced with observer handling code (bug 1121698) + def window_opened(mn): + return len(mn.chrome_window_handles) == len(start_handles) + 1 + Wait(self.marionette).until( + window_opened, + message='No new chrome window has been opened.') + + handles = self.marionette.chrome_window_handles + [new_handle] = list(set(handles) - set(start_handles)) + + assert new_handle is not None + + window = self._windows.create_window_instance(new_handle, expected_window_class) + + if focus: + window.focus() + + return window + + def send_shortcut(self, command_key, **kwargs): + """Sends a keyboard shortcut to the window. + + :param command_key: The key (usually a letter) to be pressed. + + :param accel: Optional, If `True`, the `Accel` modifier key is pressed. + This key differs between OS X (`Meta`) and Linux/Windows (`Ctrl`). Defaults to `False`. + + :param alt: Optional, If `True`, the `Alt` modifier key is pressed. Defaults to `False`. + + :param ctrl: Optional, If `True`, the `Ctrl` modifier key is pressed. Defaults to `False`. + + :param meta: Optional, If `True`, the `Meta` modifier key is pressed. Defaults to `False`. + + :param shift: Optional, If `True`, the `Shift` modifier key is pressed. + Defaults to `False`. + """ + + platform = self.marionette.session_capabilities['platformName'] + + keymap = { + 'accel': Keys.META if platform == 'darwin' else Keys.CONTROL, + 'alt': Keys.ALT, + 'cmd': Keys.COMMAND, + 'ctrl': Keys.CONTROL, + 'meta': Keys.META, + 'shift': Keys.SHIFT, + } + + # Append all to press modifier keys + keys = [] + for modifier in kwargs: + if modifier not in keymap: + raise KeyError('"%s" is not a known modifier' % modifier) + + if kwargs[modifier] is True: + keys.append(keymap[modifier]) + + # Bug 1125209 - Only lower-case command keys should be sent + keys.append(command_key.lower()) + + self.switch_to() + self.window_element.send_keys(*keys) + + def switch_to(self, focus=False): + """Switches the context to this chrome window. + + By default it will not focus the window. If that behavior is wanted, the + `focus` parameter can be used. + + :param focus: If `True`, the chrome window will be focused. + + :returns: Current window as :class:`BaseWindow` instance. + """ + if focus: + self._windows.focus(self.handle) + else: + self._windows.switch_to(self.handle) + + return self |