Add navigation among pages

Add navigation module that provides navigation across pages.
Modify existing test cases so they can benefit from the navigation module.

* Navigation class - navigation mixin that adds navigation
  functionality to PageObject class

* BaseNavigationPage - base class for pages that want to use
  navigation

Partially implements blueprint: selenium-integration-testing

Change-Id: I7a2145a599fe22613a6d5bfd6feaabd116ec0279
This commit is contained in:
Tomas Novacik 2014-09-15 11:05:05 +02:00 committed by dkorn
parent 56341524ec
commit bace1f573f
13 changed files with 444 additions and 81 deletions

View File

@ -13,7 +13,7 @@
from openstack_dashboard.test.integration_tests.pages import basepage
class OverviewPage(basepage.BasePage):
class OverviewPage(basepage.BaseNavigationPage):
def __init__(self, driver, conf):
super(OverviewPage, self).__init__(driver, conf)
self._page_title = "Usage Overview"

View File

@ -12,6 +12,7 @@
from selenium.webdriver.common import by
from openstack_dashboard.test.integration_tests.pages import navigation
from openstack_dashboard.test.integration_tests.pages import pageobject
from openstack_dashboard.test.integration_tests.regions import bars
from openstack_dashboard.test.integration_tests.regions import menus
@ -40,6 +41,7 @@ class BasePage(pageobject.PageObject):
def navaccordion(self):
return menus.NavigationAccordionRegion(self.driver, self.conf)
@property
def error_message(self):
src_elem = self._get_element(*self._error_msg_locator)
return messages.ErrorMessageRegion(self.driver, self.conf, src_elem)
@ -58,4 +60,8 @@ class BasePage(pageobject.PageObject):
return self.go_to_login_page()
def go_to_help_page(self):
self.topbar.user_dropdown_menu.click_on_help()
self.topbar.user_dropdown_menu.click_on_help()
class BaseNavigationPage(BasePage, navigation.Navigation):
pass

View File

@ -21,7 +21,6 @@ from openstack_dashboard.test.integration_tests.pages.project.compute import \
class LoginPage(pageobject.PageObject):
_login_username_field_locator = (by.By.CSS_SELECTOR, '#id_username')
_login_password_field_locator = (by.By.CSS_SELECTOR, '#id_password')
_login_submit_button_locator = (by.By.CSS_SELECTOR,
@ -32,8 +31,8 @@ class LoginPage(pageobject.PageObject):
self._page_title = "Login"
def is_login_page(self):
return self.is_the_current_page() and \
self._is_element_visible(*self._login_submit_button_locator)
return (self.is_the_current_page() and
self.is_element_visible(*self._login_submit_button_locator))
@property
def username(self):

View File

@ -0,0 +1,331 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import importlib
import types
class Navigation(object):
"""Provide basic navigation among pages.
* allows navigation among pages without need to import pageobjects
* shortest path possible should be always used for navigation to
specific page as user would navigate in the same manner
* go_to_*some_page* method respects following pattern:
* go_to_{sub_menu}_{pagename}page
* go_to_*some_page* methods are generated at runtime at module import
* go_to_*some_page* method returns pageobject
* pages must be located in the correct directories for the navigation
to work correctly
* pages modules and class names must respect following pattern
to work correctly with navigation module:
* module name consist of menu item in lower case without spaces and '&'
* page class begins with capital letter and ends with 'Page'
Examples:
In order to go to Project/Compute/Overview page, one would have to call
method go_to_compute_overviewpage()
In order to go to Admin/System/Overview page, one would have to call
method go_to_system_overviewpage()
"""
# constants
MAX_SUB_LEVEL = 4
MIN_SUB_LEVEL = 2
SIDE_MENU_MAX_LEVEL = 3
PAGES_IMPORT_PATH = "openstack_dashboard.test.integration_tests.pages.%s"
ITEMS = "__items__"
PAGE_STRUCTURE = \
{
"Project":
{
"Compute":
{
"Access & Security":
{
ITEMS:
(
"Security Groups",
"Key Pairs",
"Floating IPs",
"API Access"
),
},
"Volumes":
{
ITEMS:
(
"Volumes",
"Volume Snapshots"
)
},
ITEMS:
(
"Overview",
"Instances",
"Images",
)
},
"Network":
{
ITEMS:
(
"Network Topology",
"Networks",
"Routers"
)
},
"Object Store":
{
ITEMS:
(
"Containers",
)
},
"Data Processing":
{
ITEMS:
(
"Clusters",
"Cluster Templates",
"Node Group Templates",
"Job Executions",
"Jobs",
"Job Binaries",
"Data Sources",
"Image Registry",
"Plugins"
),
},
"Orchestration":
{
ITEMS:
(
"Stacks",
)
}
},
"Admin":
{
"System":
{
"Resource Usage":
{
ITEMS:
(
"Daily Report",
"Stats"
)
},
"System info":
{
ITEMS:
(
"Services",
"Compute Services",
"Block Storage Services",
"Network Agents",
"Default Quotas"
)
},
"Volumes":
{
ITEMS:
(
"Volumes",
"Volume Types",
"Volume Snapshots"
)
},
ITEMS:
(
"Overview",
"Hypervisors",
"Host Aggregates",
"Instances",
"Flavors",
"Images",
"Networks",
"Routers"
)
},
},
"Settings":
{
ITEMS:
(
"User Settings",
"Change Password"
)
},
"Identity":
{
ITEMS:
(
"Projects",
"Users",
"Groups",
"Domains",
"Roles"
)
}
}
# protected methods
def _go_to_page(self, path, page_class=None):
"""Go to page specified via path parameter.
* page_class parameter overrides basic process for receiving
pageobject
"""
path_len = len(path)
if path_len < self.MIN_SUB_LEVEL or path_len > self.MAX_SUB_LEVEL:
raise ValueError("Navigation path length should be in the interval"
" between %s and %s, but its length is %s" %
(self.MIN_SUB_LEVEL, self.MAX_SUB_LEVEL,
path_len))
if path_len == self.MIN_SUB_LEVEL:
# menu items that do not contain second layer of menu
if path[0] == "Settings":
self._go_to_settings_page(path[1])
else:
self._go_to_side_menu_page([path[0], None, path[1]])
else:
# side menu contains only three sub-levels
self._go_to_side_menu_page(path[:self.SIDE_MENU_MAX_LEVEL])
if path_len == self.MAX_SUB_LEVEL:
# apparently there is tabbed menu,
# because another extra sub level is present
self._go_to_tab_menu_page(path[self.MAX_SUB_LEVEL - 1])
# if there is some nonstandard pattern in page object naming
return self._get_page_class(path, page_class)(self.driver, self.conf)
def _go_to_tab_menu_page(self, item_text):
self.driver.find_element_by_link_text(item_text).click()
def _go_to_settings_page(self, item_text):
"""Go to page that is located under the settings tab."""
self.topbar.user_dropdown_menu.click_on_settings()
self.navaccordion.click_on_menu_items(third_level=item_text)
def _go_to_side_menu_page(self, menu_items):
"""Go to page that is located in the side menu (navaccordion)."""
self.navaccordion.click_on_menu_items(*menu_items)
def _get_page_cls_name(self, filename):
"""Gather page class name from path.
* take last item from path (should be python filename
without extension)
* make the first letter capital
* append 'Page'
"""
cls_name = "".join((filename.capitalize(), "Page"))
return cls_name
def _get_page_class(self, path, page_cls_name):
# last module name does not contain '_'
final_module = self.unify_page_path(path[-1],
preserve_spaces=False)
page_cls_path = ".".join(path[:-1] + (final_module,))
page_cls_path = self.unify_page_path(page_cls_path)
# append 'page' as every page module ends with this keyword
page_cls_path += "page"
page_cls_name = page_cls_name or self._get_page_cls_name(final_module)
# return imported class
module = importlib.import_module(self.PAGES_IMPORT_PATH %
page_cls_path)
return getattr(module, page_cls_name)
class GoToMethodFactory(object):
"""Represent the go_to_some_page method."""
METHOD_NAME_PREFIX = "go_to_"
METHOD_NAME_SUFFIX = "page"
METHOD_NAME_DELIMITER = "_"
# private methods
def __init__(self, path, page_class=None):
self.path = path
self.page_class = page_class
self._name = self._create_name()
def __call__(self, *args, **kwargs):
return Navigation._go_to_page(args[0], self.path, self.page_class)
# protected methods
def _create_name(self):
"""Create method name.
* consist of 'go_to_subsubmenu_menuitem_page'
"""
submenu, menu_item = self.path[-2:]
name = "".join((self.METHOD_NAME_PREFIX, submenu,
self.METHOD_NAME_DELIMITER, menu_item,
self.METHOD_NAME_SUFFIX))
name = Navigation.unify_page_path(name, preserve_spaces=False)
return name
# properties
@property
def name(self):
return self._name
# classmethods
@classmethod
def _initialize_go_to_methods(cls):
"""Create all navigation methods based on the PAGE_STRUCTURE."""
def rec(items, sub_menus):
if isinstance(items, dict):
for sub_menu, sub_item in items.iteritems():
rec(sub_item, sub_menus + (sub_menu,))
elif isinstance(items, tuple):
# exclude ITEMS element from sub_menus
paths = (sub_menus[:-1] + (menu_item,) for menu_item in items)
for path in paths:
cls._create_go_to_method(path)
rec(cls.PAGE_STRUCTURE, ())
@classmethod
def _create_go_to_method(cls, path, class_name=None):
go_to_method = Navigation.GoToMethodFactory(path, class_name)
inst_method = types.MethodType(go_to_method, None, Navigation)
setattr(Navigation, inst_method.name, inst_method)
@classmethod
def unify_page_path(cls, path, preserve_spaces=True):
"""Unify path to page.
Replace '&' in path with 'and', remove spaces (if not specified
otherwise) and convert path to lower case.
"""
path = path.replace("&", "and")
path = path.lower()
if preserve_spaces:
path = path.replace(" ", "_")
else:
path = path.replace(" ", "")
return path
Navigation._initialize_go_to_methods()

View File

@ -1,42 +0,0 @@
# Copyright 2014 Hewlett-Packard Development Company, L.P
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from selenium.webdriver.common import by
from openstack_dashboard.test.integration_tests.pages import basepage
from openstack_dashboard.test.integration_tests.pages.project.compute.\
access_and_security import keypairpage
class AccessSecurityPage(basepage.BasePage):
_keypair_link_locator = (
by.By.CSS_SELECTOR,
'a[href*="?tab=access_security_tabs__keypairs_tab"]')
def __init__(self, driver, conf):
super(AccessSecurityPage, self).__init__(driver, conf)
self._page_title = "Access & Security"
@property
def keypair_link(self):
return self._get_element(*self._keypair_link_locator)
def _click_on_keypair_link(self):
self.keypair_link.click()
def go_to_keypair_page(self):
self._click_on_keypair_link()
return keypairpage.KeypairPage(self.driver, self.conf)

View File

@ -20,7 +20,7 @@ from openstack_dashboard.test.integration_tests.regions import forms
from openstack_dashboard.test.integration_tests.regions import tables
class KeypairPage(basepage.BasePage):
class KeypairsPage(basepage.BaseNavigationPage):
_key_pairs_table_locator = (by.By.CSS_SELECTOR, 'table#keypairs')
@ -32,7 +32,7 @@ class KeypairPage(basepage.BasePage):
CREATE_KEY_PAIR_FORM_FIELDS = ('name',)
def __init__(self, driver, conf):
super(KeypairPage, self).__init__(driver, conf)
super(KeypairsPage, self).__init__(driver, conf)
self._page_title = "Access & Security"
def _get_row_with_keypair_name(self, name):

View File

@ -12,15 +12,11 @@
from selenium.webdriver.common import by
from openstack_dashboard.test.integration_tests.pages import basepage
from openstack_dashboard.test.integration_tests.pages.project.compute.\
access_and_security import accesssecuritypage
from openstack_dashboard.test.integration_tests.pages.settings import \
settingspage
from openstack_dashboard.test.integration_tests.regions import forms
from openstack_dashboard.test.integration_tests.regions import tables
class OverviewPage(basepage.BasePage):
class OverviewPage(basepage.BaseNavigationPage):
_usage_table_locator = (by.By.CSS_SELECTOR, 'table#project_usage')
_date_form_locator = (by.By.CSS_SELECTOR, 'form#date_form')
@ -30,19 +26,6 @@ class OverviewPage(basepage.BasePage):
super(OverviewPage, self).__init__(driver, conf)
self._page_title = 'Instance Overview'
def go_to_settings_page(self):
self.topbar.user_dropdown_menu.click_on_settings()
return settingspage.SettingsPage(self.driver, self.conf)
def go_to_accesssecurity_page(self):
access_security_locator_flag = self._is_element_visible(
*self.navaccordion._project_access_security_locator)
if not access_security_locator_flag:
self.navaccordion.project_bar.click()
self.navaccordion.access_security.click()
return accesssecuritypage.AccessSecurityPage(
self.driver, self.conf)
@property
def usage_table(self):
src_elem = self._get_element(*self._usage_table_locator)

View File

@ -16,7 +16,7 @@ from openstack_dashboard.test.integration_tests.pages import basepage
from openstack_dashboard.test.integration_tests.regions import forms
class ChangePasswordPage(basepage.BasePage):
class ChangepasswordPage(basepage.BaseNavigationPage):
_password_form_locator = (by.By.CSS_SELECTOR,
'div#change_password_modal')

View File

@ -18,7 +18,7 @@ from openstack_dashboard.test.integration_tests.pages.settings import \
from openstack_dashboard.test.integration_tests.regions import forms
class SettingsPage(basepage.BasePage):
class UsersettingsPage(basepage.BaseNavigationPage):
DEFAULT_LANGUAGE = "en"
DEFAULT_TIMEZONE = "UTC"
DEFAULT_PAGESIZE = "20"
@ -35,7 +35,7 @@ class SettingsPage(basepage.BasePage):
'a[href*="/settings/password/"]')
def __init__(self, driver, conf):
super(SettingsPage, self).__init__(driver, conf)
super(UsersettingsPage, self).__init__(driver, conf)
self._page_title = "User Settings"
@property

View File

@ -9,12 +9,15 @@
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from selenium.webdriver.common import by
from openstack_dashboard.test.integration_tests.regions import baseregion
class CannotClickMenuItemException(Exception):
pass
class NavigationAccordionRegion(baseregion.BaseRegion):
"""Navigation menu located in the left."""
_project_access_security_locator = (
@ -25,10 +28,43 @@ class NavigationAccordionRegion(baseregion.BaseRegion):
".//*[@id='main_content']//div[contains(text(),"
"'Project')]")
MAX_MENU_ITEM_CLICK_TRIES = 100
@property
def project_bar(self):
return self._get_element(*self._project_bar_locator)
_first_level_item_selected_locator = (by.By.CSS_SELECTOR, 'dt.active')
_second_level_item_selected_locator = (by.By.CSS_SELECTOR, 'h4.active')
_first_level_item_xpath_template = '//dt[contains(text(),\'%s\')]'
_second_level_item_xpath_template = '//h4[contains(text(),\'%s\')]'
_third_level_item_xpath_template = '//li/a[text()=\'%s\']'
def _get_first_level_item_locator(self, text):
return (by.By.XPATH,
self._first_level_item_xpath_template % text)
def _get_second_level_item_locator(self, text):
return (by.By.XPATH,
self._second_level_item_xpath_template % text)
def _get_third_level_item_locator(self, text):
return (by.By.XPATH,
self._third_level_item_xpath_template % text)
def get_first_level_selected_item(self):
if self._is_element_present(*self._first_level_item_selected_locator):
return self._get_element(*self._first_level_item_selected_locator)
else:
return None
def get_second_level_selected_item(self):
if self._is_element_present(*self._second_level_item_selected_locator):
return self._get_element(*self._second_level_item_selected_locator)
else:
return None
@property
def access_security(self):
return self._get_element(*self._project_access_security_locator)
@ -37,6 +73,58 @@ class NavigationAccordionRegion(baseregion.BaseRegion):
def change_password(self):
return self._get_element(*self._settings_change_password_locator)
def _click_menu_item(self, text, loc_craft_func, get_selected_func=None):
"""Click on menu item if not selected.
Menu animation that visualize transition from one selection to
another take some time - if clicked on item during this animation
nothing happens, therefore it is necessary to do this in a loop.
"""
if not get_selected_func:
self._click_item(text, loc_craft_func)
else:
for _ in xrange(self.MAX_MENU_ITEM_CLICK_TRIES):
selected_item = get_selected_func()
if selected_item and text == selected_item.text:
break
# In case different item was chosen previously scroll it,
# because otherwise selenium will complain with
# MoveTargetOutOfBoundsException
if selected_item:
selected_item.click()
self._click_item(text, loc_craft_func)
else:
# One should never get in here,
# this suggest that something is wrong
raise CannotClickMenuItemException()
def _click_item(self, text, loc_craft_func):
"""Click on item."""
item_locator = loc_craft_func(text)
item = self._get_element(*item_locator)
item.click()
def click_on_menu_items(self, first_level=None,
second_level=None,
third_level=None):
if first_level:
self._click_menu_item(first_level,
self._get_first_level_item_locator,
self.get_first_level_selected_item)
if second_level:
self._click_menu_item(second_level,
self._get_second_level_item_locator,
self.get_second_level_selected_item)
# it is not checked that third level item is clicked because behaviour
# of the third menu layer is buggy => always click
if third_level:
self._click_menu_item(third_level,
self._get_third_level_item_locator)
class DropDownMenuRegion(baseregion.BaseRegion):
"""Drop down menu region."""

View File

@ -23,12 +23,10 @@ class TestKeypair(helpers.TestCase):
KEYPAIR_NAME = 'horizonkeypair_' + str(uuid.uuid4())
def test_keypair(self):
accesssecurity_page = self.home_pg.go_to_accesssecurity_page()
keypair_page = accesssecurity_page.go_to_keypair_page()
keypair_page = self.home_pg.go_to_accessandsecurity_keypairspage()
keypair_page.create_keypair(self.KEYPAIR_NAME)
accesssecurity_page = self.home_pg.go_to_accesssecurity_page()
keypair_page = accesssecurity_page.go_to_keypair_page()
keypair_page = self.home_pg.go_to_accessandsecurity_keypairspage()
self.assertTrue(keypair_page.is_keypair_present(self.KEYPAIR_NAME))
keypair_page.delete_keypair(self.KEYPAIR_NAME)

View File

@ -21,8 +21,7 @@ class TestPasswordChange(helpers.TestCase):
"""Changes the password, verifies it was indeed changed and resets to
default password.
"""
settings_page = self.home_pg.go_to_settings_page()
passwordchange_page = settings_page.go_to_change_password_page()
passwordchange_page = self.home_pg.go_to_settings_changepasswordpage()
try:
passwordchange_page.change_password(self.conf.identity.password,
@ -32,8 +31,9 @@ class TestPasswordChange(helpers.TestCase):
user=self.conf.identity.username, password=NEW_PASSWORD)
self.assertTrue(self.home_pg.is_logged_in,
"Failed to login with new password")
settings_page = self.home_pg.go_to_settings_page()
passwordchange_page = settings_page.go_to_change_password_page()
settings_page = self.home_pg.go_to_settings_usersettingspage()
passwordchange_page = settings_page.\
go_to_settings_changepasswordpage()
finally:
passwordchange_page.reset_to_default_password(NEW_PASSWORD)
self.login_pg.login()

View File

@ -36,7 +36,7 @@ class TestUserSettings(helpers.TestCase):
* changes the number of items per page (page size)
* verifies all changes were successfully executed
"""
self.settings_page = self.home_pg.go_to_settings_page()
self.settings_page = self.home_pg.go_to_settings_usersettingspage()
self.settings_page.change_language("es")
self.settings_page.change_timezone("Asia/Jerusalem")