import warnings
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver import ActionChains
from webdriver_test_tools.pageobject import utils, BasePage, YAMLParsingPageObject
from webdriver_test_tools.webdriver import actions
# Link Page Objects
[docs]class NavLinkObject(BasePage):
"""Page object prototype for nav links"""
[docs] class ActionTypes:
"""Link click/hover action types"""
PAGE = 'page'
SECTION = 'section'
MENU = 'menu'
NONE = 'none'
# Click/hover support for each type
CLICK_ACTIONS = [
PAGE, SECTION, MENU, NONE, None
]
HOVER_ACTIONS = [
MENU, NONE, None
]
# Required attributes for types
REQUIRES_TARGET = [
PAGE, SECTION
]
def __init__(self, driver, link_dict, site_config):
"""Initialize ``NavLinkObject`` using parsed YAML or link dictionary
See :ref:`YAML links documentation <yaml-links>` for details on
``link_dict`` syntax
:param driver: Selenium WebDriver object
:param link_dict: Link dictionary using syntax specified in :ref:`YAML
links documentation <yaml-links>`. Must have 'name' and
'link_locator' keys set
:param site_config: Test project's :class:`SiteConfig` class. Used to
determine any relative URLs specified in the 'target'
:raises ValueError: if any of the keys in ``link_dict`` are set to
invalid values
:raises KeyError: if any of the required keys in ``link_dict`` are
missing
"""
super().__init__(driver)
# 'name' and 'link_locator' required, so assume that they're valid keys
# and raise errors otherwise
self.name = link_dict['name']
self.locator = utils.yaml.parse_locator_dict(link_dict['link_locator'])
self.click_action = link_dict.get('click', self.ActionTypes.PAGE)
if self.click_action not in self.ActionTypes.CLICK_ACTIONS:
error_msg = "Invalid value 'click' action for link (link: {}). ".format(str(link_dict))
error_msg += 'Valid click actions: {}'.format(str(self.ActionTypes.CLICK_ACTIONS))
raise ValueError(error_msg)
if self.click_action == self.ActionTypes.NONE:
self.click_action = None
# Click target should also be None
self.target = None
self.hover_action = link_dict.get('hover', None)
if self.hover_action and self.hover_action not in self.ActionTypes.HOVER_ACTIONS:
error_msg = "Invalid value 'hover' action for link (link: {}). ".format(str(link_dict))
error_msg += 'Valid hover actions: {}'.format(str(self.ActionTypes.HOVER_ACTIONS))
raise ValueError(error_msg)
if self.hover_action == self.ActionTypes.NONE:
self.hover_action = None
# Get target attribute if required
if self.click_action in self.ActionTypes.REQUIRES_TARGET:
target = link_dict['target']
# Add '#' to the front of section targets if not present
if self.click_action == self.ActionTypes.SECTION and not target.startswith('#'):
target = '#' + target
if isinstance(target, dict):
target = site_config.parse_relative_url_dict(target)
self.target = target
# Parse menu if applicable
if self.ActionTypes.MENU in [self.click_action, self.hover_action]:
self.menu = NavMenuObject(self.driver, link_dict['menu'], site_config)
# WebElement retrieval
[docs] def find_link_element(self):
"""Returns the ``WebElement`` object located by ``self.locator``
Shorthand for ``self.find_element(self.locator)``
:return: ``WebElement`` object for the link
"""
return self.find_element(self.locator)
# Actions
[docs] def click_link(self, scroll_to=False):
"""Click the link
:param scroll_to: (Default: False) If True, scroll to the link first
before clicking (used for non-fixed navs)
:return: Return value depends on ``self.click_action``:
* 'page': Returns the URL to the link target
* 'section': Returns the target section ID (prefixed with '#')
* 'menu': Returns a :class:`NavMenuObject` instance
* None: Returns ``None``
"""
link_element = self.find_link_element()
if scroll_to:
actions.scroll.into_view(self.driver, link_element, False)
link_element.click()
return self.menu if self.click_action == self.ActionTypes.MENU else self.target
[docs] def hover_over_link(self, scroll_to=False):
"""Hover over the link element
:param scroll_to: (Default: False) If True, scroll to the link first
before hovering (used for non-fixed navs)
:return: :class:`NavMenuObject` instance (or ``None`` if no hover
action is defined)
"""
link_element = self.find_link_element()
if scroll_to:
actions.scroll.into_view(self.driver, link_element, False)
action_chain = ActionChains(self.driver)
action_chain.move_to_element(link_element).perform()
return self.menu if self.hover_action == self.ActionTypes.MENU else None
# Menu Page Objects
# Navbar Page Objects
[docs]class NavObject(YAMLParsingPageObject):
"""Page object prototype for navbars
Subclasses should set the following attributes:
:var NavObject.YAML_FILE: Path to a YAML file representing the navbar
:var NavObject.SITE_CONFIG: Test project's :class:`SiteConfig` class.
Used for :class:`NavLinkObject` instances to determine any relative
URLs specified for link targets
The following attributes are determined based on the contents of
:attr:`YAML_FILE` (or should be set in subclasses if :attr:`YAML_FILE` is
``None``):
:var NavObject.FIXED: (Default: True) True if element is a fixed navbar,
False otherwise. If set to False in a subclass,
:meth:`click_page_link()` and :meth:`hover_over_page_link()` will
scroll the target link into view before interacting with it
:var NavObject.COLLAPSIBLE: (Default: False) True if the navbar is
collapsible (e.g. for hamburger menus). If set to True,
:attr:`MENU_LOCATOR`, :attr:`EXPAND_BUTTON_LOCATOR`, and (optionally)
:attr:`COLLAPSE_BUTTON_LOCATOR` should also be set
For collapsible navs, these additional attributes should also be specified
in :attr:`YAML_FILE` (or should be set in subclasses if :attr:`YAML_FILE`
is ``None``):
:var NavObject.MENU_LOCATOR: (Required for collapsible) Locator for the
collapsible menu element
:var NavObject.EXPAND_BUTTON_LOCATOR: (Required for collapsible) Locator
for the button that expands the nav menu
:var NavObject.COLLAPSE_BUTTON_LOCATOR: (Optional) Locator for the button
that collapses the nav menu. If unspecified, this will be set to the
same value as :attr:`EXPAND_BUTTON_LOCATOR`
.. note::
``MENU_LOCATOR``, ``EXPAND_BUTTON_LOCATOR``, and
``COLLAPSE_BUTTON_LOCATOR`` will only be parsed from YAML if not
already set in the subclass. This allows for a non-collapsible nav that
parses YAML to be extended in a subclass with ``COLLAPSIBLE = True``
without re-writing the YAML file. The subclass would only need to set
``MENU_LOCATOR``, ``EXPAND_BUTTON_LOCATOR``, and (optionally)
``COLLAPSE_BUTTON_LOCATOR``
The following attribute is set based on the 'links' key parsed from
:attr:`YAML_FILE` (or parsed from :attr:`LINK_DICTS`, which should be set
in subclasses if :attr:`YAML_FILE` is ``None``):
:var NavObject.links: A dictionary mapping link names to the corresponding
:class:`NavLinkObject` instances. The keys correspond with the ``name``
keys in the YAML representation of the navbar (or the 'name' keys in
:attr:`LINK_DICTS` if :attr:`YAML_FILE` is ``None``)
If :attr:`YAML_FILE` is ``None``, subclasses must set the following
attribute:
:var NavObject.LINK_DICTS: List of link dictionaries. These are used to
initialize the :class:`NavLinkObject` instances in :attr:`links` at
runtime. These dictionaries use the same syntax as :ref:`YAML links
<yaml-links>`
"""
_YAML_ROOT_KEY = 'nav'
SITE_CONFIG = None
# General nav attributes
FIXED = True
# Collapsible attributes
COLLAPSIBLE = False
MENU_LOCATOR = None
COLLAPSE_BUTTON_LOCATOR = None
EXPAND_BUTTON_LOCATOR = None
# Link objects
LINK_DICTS = []
links = {}
# Initialization
[docs] def parse_yaml(self, file_path):
"""Parse a YAML representation of the nav object and set attributes
accordingly
See :ref:`YAML NavObjects doc <yaml-nav-objects>` for details on
syntax.
:param file_path: Full path to the YAML file
"""
parsed_yaml = super().parse_yaml(file_path)
self.FIXED = parsed_yaml.get('fixed', True)
# Only assign if attribute is present (allows subclasses of
# non-collapsible navs to override COLLAPSIBLE)
if 'collapsible' in parsed_yaml:
self.COLLAPSIBLE = parsed_yaml['collapsible']
# Collapsible nav configurations
if self.COLLAPSIBLE:
try:
# Only do the following if these elements weren't explicitly
# defined in the class. This allows for collapsible variants of
# existing nav classes to be defined using subclasses
if self.MENU_LOCATOR is None:
self.MENU_LOCATOR = utils.yaml.parse_locator_dict(parsed_yaml['menu_locator'])
if self.EXPAND_BUTTON_LOCATOR is None:
self.EXPAND_BUTTON_LOCATOR = utils.yaml.parse_locator_dict(parsed_yaml['expand_button_locator'])
if self.COLLAPSE_BUTTON_LOCATOR is None and 'collapse_button_locator' in parsed_yaml:
self.COLLAPSE_BUTTON_LOCATOR = utils.yaml.parse_locator_dict(parsed_yaml['collapse_button_locator'])
else:
self.COLLAPSE_BUTTON_LOCATOR = self.EXPAND_BUTTON_LOCATOR
except KeyError as e:
error_msg = 'Missing required {} key in collapsible nav YAML. '.format(e)
error_msg += "If 'collapsible' is set to true, 'expand_button_locator' and 'menu_locator' must also be set"
raise utils.yaml.YAMLKeyError(error_msg)
self._initialize_links(parsed_yaml['links'])
[docs] def no_yaml_init(self):
"""Initialize ``self.links`` using values in :attr:`LINK_DICTS`"""
if self.COLLAPSIBLE:
if not self.COLLAPSE_BUTTON_LOCATOR:
self.COLLAPSE_BUTTON_LOCATOR = self.EXPAND_BUTTON_LOCATOR
self._initialize_links(self.LINK_DICTS, from_yaml=False)
def _initialize_links(self, link_dicts, from_yaml=True):
"""Initialize :class:`NavLinkObject` instances in ``self.links``
:param link_dicts: List of link dictionaries
:param from_yaml: (Default: True) Whether or not this was parsed from
YAML. Exceptions raised will be different based on this
"""
# TODO: verify and document exceptions
self.links = {}
for link_dict in link_dicts:
try:
link_name = link_dict['name']
# Link names must be unique
if link_name in self.links:
error_msg = "Multiple links with the same 'name' value (name: {}). ".format(link_name)
error_msg += 'link names must be unique'
raise utils.yaml.YAMLValueError(error_msg) if from_yaml else ValueError(error_msg)
# Initialize NavLinkObject
self.links[link_name] = NavLinkObject(self.driver, link_dict, self.SITE_CONFIG)
except KeyError as e:
if from_yaml:
error_message = 'Missing required {} key in link YAML (link: {})'.format(e, str(link_dict))
raise utils.yaml.YAMLKeyError(error_message)
# Preserve stack trace for key error if not parsing YAML
else:
raise
except ValueError as e:
# Raise YAML error if applicable
if from_yaml:
raise utils.yaml.YAMLKeyError(error_msg)
# Preserve stack trace for key error if not parsing YAML
else:
raise
# Nav Actions
[docs] def click_link(self, link_name):
"""Click a link on the navbar
:param link_name: Name of the link (specified in YAML or link
dictionary) i.e. a valid key in ``self.links``
:return: The returned value of clicking the link. See
:meth:`NavLinkObject.click_link` for possible values
:raises KeyError: If ``link_name`` is not a valid key in ``self.links``
"""
return self.links[link_name].click_link(scroll_to=not self.FIXED)
[docs] def hover_over_link(self, link_name):
"""Hover over a link in the navbar
:param link_name: Name of the link (specified in YAML or link
dictionary) i.e. a valid key in ``self.links``
:return: The returned value of hovering over the link. See
:meth:`NavLinkObject.hover_over_link` for possible values
:raises KeyError: If ``link_name`` is not a valid key in ``self.links``
"""
return self.links[link_name].hover_over_link(scroll_to=not self.FIXED)
# Collapsible Nav Actions
[docs] def is_expanded(self):
"""Check if the nav menu is expanded
.. note::
For collapsible navs only. If :attr:`COLLAPSIBLE` is ``False``, a
warning will be raised and the method will return
This method checks if the element located by :attr:`MENU_LOCATOR`
exists and is visible. This should be sufficient for many common
implementations of collapsible navs, but can be overridden if this
isn't a reliable detection method for an implementation
:return: True if the nav menu is expanded, False if it's collapsed
"""
if not self.COLLAPSIBLE:
warnings.warn(
'NavObject.is_expanded() called on a non-collapsible NavObject, method will not be executed'
)
return
try:
expanded = self.find_element(self.MENU_LOCATOR).is_displayed()
except NoSuchElementException:
expanded = False
return expanded