Selenium infra

Add Selenium to the tempest plugin

Change-Id: I53207b321e8dabda1e47c4d9f01cdd6e1682bb69
This commit is contained in:
Udi Kalifon 2018-09-26 15:10:28 +02:00
parent a0fc5efc88
commit 3048d7eed3
14 changed files with 883 additions and 21 deletions

View File

@ -18,6 +18,21 @@ Using
Install this plugin in the same python environment as tempest.
Installing the web driver
-------------------------
Selenium drives the browser by using a web driver. Currently Firefox and Chrome are supported.
#. To install the Firefox driver:
Download the latest driver from https://github.com/mozilla/geckodriver/releases/. Place the
executable somewhere in your ``$PATH``, and configure your tempest.conf with the full path to
it. See an example tempest.conf file below.
#. To install the Chrome driver:
Download the latest driver from https://sites.google.com/a/chromium.org/chromedriver/downloads.
Unzip the downloaded file you'll get an executable called "chromedriver". Place the executable
somewhere in your ``$PATH``.
Configuration for testing
-------------------------
@ -35,3 +50,38 @@ On your undercloud:
This will run all of the tests contained in the tempest-tripleo-ui plugin
against your undercloud.
Sample tempest.conf
-------------------
For the UI tests to work, a minimal tempest.conf should include:
1) The credentials to log in (same credentials which are used on the command line)
2) The URL where the login screen to the UI can be found
3) The webdriver to use, which could be one of: "Chrome" or "Firefox"
4) If using Firefox, set marionette_binary to point to the path to the driver
[DEFAULT]
log_dir = /home/tester/src/tempest/cloud-01/logs
log_file = tempest.log
[oslo_concurrency]
lock_path = /home/tester/src/tempest/cloud-01/tempest_lock
[auth]
admin_username = admin
admin_password = password
admin_project_name = admin
admin_domain_name = default
[identity]
auth_version = v3
uri_v3 = https://server:443/keystone/v3
[tripleo_ui]
webdriver = "Chrome"
marionette_binary = "/home/tester/bin/wires"
url = "https://server"
[logger_root]
level=DEBUG
handlers=file

View File

@ -2,3 +2,4 @@ pbr!=2.1.0,>=2.0.0 # Apache-2.0
oslo.config>=5.1.0 # Apache-2.0
six>=1.10.0 # MIT
tempest>=17.1.0 # Apache-2.0
selenium>=3.14.1 # Apache-2.0

View File

@ -0,0 +1,68 @@
from selenium.common import exceptions
from tempest_tripleo_ui.core import SeleniumElement
from tempest_tripleo_ui.timer import Timer
from time import sleep
import logging
logger = logging.getLogger(__name__)
alert_success_close_button = SeleniumElement.by_xpath(
'//div[contains(@class, "alert-success")]/button[@class="close"]')
alert_success = SeleniumElement.by_xpath(
'//div[contains(@class, "alert-success")]')
alert_danger_close_button = SeleniumElement.by_xpath(
'//div[contains(@class, "alert-danger")]/button[@class="close"]')
alert_danger = SeleniumElement.by_xpath(
'//div[contains(@class, "alert-danger")]')
# the spinner that we see while waiting for dialogs to open etc'
spinner = SeleniumElement.by_xpath(
'//div[contains(@class, "spinner")]')
def clear_success_message(timeout=3):
if alert_success_close_button.wait_for_element(timeout) and \
not alert_success_close_button.is_stale():
try:
logger.info("Clearing message: {}".format(
alert_success.get_all_text()))
alert_success_close_button.click()
except exceptions.WebDriverException:
pass # sometimes the message disappears in this critical moment
return True
return False
def clear_danger_message(timeout=3):
if alert_danger_close_button.wait_for_element(timeout) and \
not alert_danger_close_button.is_stale():
logger.error("Clearing alert: {}".format(
alert_danger.get_all_text()))
alert_danger_close_button.click()
return True
return False
def clear_all_success_messages():
while clear_success_message():
sleep(2)
def clear_all_danger_messages():
while clear_danger_message():
sleep(2)
def wait_for_spinner(timeout=60, spinner_element=None):
logger.debug("waiting for spinners...")
if not spinner_element:
spinner_element = spinner
timer = Timer("spinner")
while spinner_element.wait_for_element(0) and \
timer.get_duration() < timeout:
sleep(3)
# not using timer.assert_timer() because we're not interested
# in the error message it generates
return timer.get_duration() < timeout

View File

@ -0,0 +1,36 @@
import logging
from tempest.lib import exceptions
from tempest import test
from tempest_tripleo_ui import browser
from tempest_tripleo_ui.models import login
logger = logging.getLogger(__name__)
class CouldNotLogIn(exceptions.TempestException):
message = "Could not log in"
class GUITestCase(test.BaseTestCase):
@classmethod
def resource_setup(cls):
"""Login to the GUI. This will also open up the browser and get the
home page - according to the parameters configured in the [UI]
section in the conf file.
"""
super(GUITestCase, cls).resource_setup()
if not login.login():
logger.error(
"Unable to login to TripleO UI (check the "
"credentials in the tempest conf file)")
browser.quit_browser()
raise CouldNotLogIn
cls.addClassResourceCleanup(cls.logout_and_close_browser)
@classmethod
def logout_and_close_browser(cls):
if login.is_logged_in():
login.logout()
browser.quit_browser()

View File

@ -0,0 +1,111 @@
import logging
from selenium import webdriver
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
from tempest import config
from tempest.lib import exceptions
import uuid
class InvalidWebdriverSetting(exceptions.TempestException):
message = "Invalid webdriver: %(browser_driver)s. Should be either "\
"'Chrome' or 'Firefox'"
logger = logging.getLogger(__name__)
CONF = config.CONF
browser = None
def get_browser():
global browser
if browser is not None:
return browser
browser_driver = CONF.tripleo_ui.webdriver
if browser_driver.lower() == "chrome":
browser_factory = ChromeFactory()
elif browser_driver.lower() == "firefox":
browser_factory = MarionetteFactory(
CONF.tripleo_ui.marionette_binary)
else:
raise InvalidWebdriverSetting(browser_driver=browser_driver)
browser = Browser(browser_factory, CONF.tripleo_ui.url)
return browser
def quit_browser():
global browser
if browser is None:
return
browser.quit_browser()
browser = None
def save_screenshot(filename=None):
global browser
if browser is None:
return
if not filename:
filename = "/tmp/{}.png".format(uuid.uuid4())
browser.get_driver().save_screenshot(filename)
return filename
def execute_script(script_to_run):
return get_browser().execute_script(script_to_run)
class Browser(object):
def __init__(self, browser_factory, homepage=None):
self.session = None
self.driver = browser_factory.create()
if homepage and len(homepage.strip()):
self.get(homepage)
def get_session(self):
if self.session:
return self.session
self.session = uuid.uuid4()
return self.session
def get(self, url):
self.driver.get(url)
def refresh(self):
self.driver.refresh()
def get_driver(self):
return self.driver
def quit_browser(self):
self.driver.quit()
self.driver = None
self.session = None
def execute_script(self, script_to_run):
return self.driver.execute_script(script_to_run)
class ChromeFactory(object):
def create(self):
return webdriver.Chrome()
class MarionetteFactory(object):
def __init__(self, path_to_binary):
self.path_to_binary = path_to_binary
def create(self):
capabilities = DesiredCapabilities.FIREFOX
capabilities['marionette'] = True
args = dict()
args['capabilities'] = capabilities
args['executable_path'] = self.path_to_binary
return webdriver.Firefox(**args)

View File

@ -21,5 +21,9 @@ ui_group = cfg.OptGroup(name='tripleo_ui', title='TripleO UI Options')
UIGroup = [
cfg.StrOpt('url', default='http://localhost',
help='where the TripleO UI can be found')
help='where the TripleO UI can be found'),
cfg.StrOpt('webdriver', default='Chrome',
help='the browser to use for the test [Chrome/Marionette]'),
cfg.StrOpt('marionette_binary', default='path_to_wires',
help='path to the marionette driver binary')
]

395
tempest_tripleo_ui/core.py Normal file
View File

@ -0,0 +1,395 @@
import logging
from selenium.common import exceptions
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support import select
from selenium.webdriver.support import ui
from tempest_tripleo_ui import browser
from tempest_tripleo_ui.decorators import selenium_action
from time import sleep
logger = logging.getLogger(__name__)
class Identifier(object):
def __init__(self, by, identifier):
self.by = by
self.identifier = identifier
@classmethod
def xpath(cls, locator):
return Identifier(By.XPATH, locator)
@classmethod
def css_selector(cls, locator):
return Identifier(By.CSS_SELECTOR, locator)
@classmethod
def id(cls, locator):
return Identifier(By.ID, locator)
@classmethod
def class_name(cls, locator):
return Identifier(By.CLASS_NAME, locator)
@classmethod
def link_text(cls, locator):
return Identifier(By.LINK_TEXT, locator)
class SeleniumElement(object):
def __init__(self,
identifier=None,
webelement=None,
session=None):
self.identifier = identifier
self.webelement = webelement
self.session = session
@classmethod
def by_id(cls, name):
return SeleniumElement(Identifier(By.ID, name))
@classmethod
def by_class(cls, name):
return SeleniumElement(Identifier(By.CLASS_NAME, name))
@classmethod
def by_css(cls, name):
return SeleniumElement(Identifier(By.CSS_SELECTOR, name))
@classmethod
def by_xpath(cls, name):
return SeleniumElement(Identifier(By.XPATH, name))
@classmethod
def by_link_text(cls, name):
return SeleniumElement(Identifier(By.LINK_TEXT, name))
@classmethod
def by_element(cls, elem, browser_session):
new_elem = SeleniumElement(webelement=elem, session=browser_session)
return new_elem
def get_element(self):
if self.webelement and \
self.session == browser.get_browser().get_session():
return self.webelement
elem = self.wait_for_element()
if elem is None:
logger.error("can't find element: {}".format(
self.get_identifier_string()))
return elem
def get_identifier_string(self):
name = "No identifier"
if self.identifier:
name = self.identifier.identifier
return name
def wait_for_element(self, timeout_in_seconds=10):
if self.identifier is None:
logger.warning("wait_for_element() is irrelevant for elements "
"that were not found via an identifier")
return self.webelement
try:
self.webelement = ui.WebDriverWait(
browser.get_browser().get_driver(),
timeout_in_seconds).until(
EC.presence_of_element_located(
(self.identifier.by, self.identifier.identifier))
)
self.session = browser.get_browser().get_session()
return self.webelement
except exceptions.TimeoutException:
return None # because the element is not found
def wait_for_present_text(self, text, timeout_in_seconds=10):
try:
if self.identifier is None:
logger.warning(
'wait_for_present_text will not work properly, '
'because there is missing .identifier.')
self.webelement = ui.WebDriverWait(
browser.get_browser().get_driver(),
timeout_in_seconds).until(
EC.text_to_be_present_in_element(
(self.identifier.by, self.identifier.identifier), text)
)
return self.webelement
except exceptions.TimeoutException:
return None
def wait_for_staleness(self, timeout_in_seconds=10):
if self.webelement is None:
logger.warning("wait_for_staleness() is irrelevant for elements "
"that were not found yet")
return True
try:
return ui.WebDriverWait(browser.get_browser().get_driver(),
timeout_in_seconds).until(
EC.staleness_of(self.webelement))
except exceptions.TimeoutException:
return None
def is_stale(self):
try:
self.webelement.is_enabled()
return False
except exceptions.StaleElementReferenceException:
return True
except exceptions.WebDriverException:
return False
@selenium_action
def is_visible(self, webelement):
return webelement.is_displayed()
@selenium_action
def is_selected(self, webelement):
return webelement.is_selected()
@selenium_action
def is_enabled(self, webelement):
return webelement.is_enabled()
@selenium_action
def get_tag_name(self, webelement):
return webelement.tag_name
@selenium_action
def get_text(self, webelement):
return webelement.text
def get_all_text(self):
text = self.get_text()
if text is None or len(text) == 0:
text = ""
child_elements = self.find_child_elements(Identifier.xpath("*"))
if child_elements is not None and len(child_elements) > 0:
for child_element in child_elements:
child_text = child_element.get_all_text()
if child_text is not None and len(child_text) > 0:
if len(text) > 0:
text = text + " " + child_text
else:
text = child_text
return text
@selenium_action
def get_id(self, webelement):
return webelement.get_attribute("id")
@selenium_action
def get_name(self, webelement):
return webelement.get_attribute("name")
@selenium_action
def get_value(self, webelement):
return webelement.get_attribute("value")
@selenium_action
def get_class(self, webelement):
return webelement.get_attribute("class")
@selenium_action
def get_href(self, webelement):
return webelement.get_attribute("href")
@selenium_action
def get_title(self, webelement):
return webelement.get_attribute("title")
@selenium_action
def click_minimum_retries(self, webelement):
webelement.click()
# this WON'T be a @selenium_action because we're handling retries
# internally
def click(self, use_js=False):
# try to click and catch exception
try:
self.click_minimum_retries()
return True
except exceptions.WebDriverException:
pass
sleep(1)
# click failed - scroll the item into view (bottom) and try again
try:
self.scroll_into_view(False)
self.click_minimum_retries()
return True
except exceptions.WebDriverException:
pass
sleep(1)
# click failed again - scroll the item into view (top) and try again
try:
self.scroll_into_view(True)
self.click_minimum_retries()
return True
except exceptions.WebDriverException:
pass
if use_js:
try:
logger.warning("almost giving up on all click attempts !!")
browser.get_browser().save_screenshot()
browser.get_browser().get_driver().execute_script(
'return arguments[0].click();', self.get_element()
)
logger.warning(
"click was successful only when using "
"javascript... {}".format(self.get_identifier_string()))
sleep(1)
browser.get_browser().save_screenshot()
return True
except BaseException:
logger.warning(
"click was unsuccessful even with javascript! {}".format(
self.get_identifier_string()))
# all failed...
logger.error(
"all click attempts failed on: {}".format(
self.get_identifier_string()))
return False
@selenium_action
def click_at_offset(self, webelement, x, y):
actions = webdriver.ActionChains(browser.get_browser().get_driver())
actions.move_to_element_with_offset(webelement, x, y)
actions.click()
return actions.perform()
@selenium_action
def move_to(self, webelement):
actions = webdriver.ActionChains(browser.get_browser().get_driver())
actions.move_to_element(webelement)
actions.perform()
@selenium_action
def scroll_into_view(self, webelement, align_to_top=True):
driver = browser.get_browser().get_driver()
if align_to_top:
driver.execute_script(
"arguments[0].scroll_into_view(true);", webelement)
else:
driver.execute_script(
"arguments[0].scroll_into_view(false);", webelement)
@selenium_action
def submit(self, webelement):
return webelement.submit()
@selenium_action
def clear(self, webelement):
webelement.clear()
return webelement.get_attribute('value') == ''
@selenium_action
def send_keys(self, webelement, text, tokenize=True):
text = "{}".format(text)
if not tokenize:
webelement.send_keys(text)
return
# don't send more than 40 characters at a time, as it
# can hang some scripts
token_size = 40
while len(text) > 0:
webelement.send_keys(text[:token_size])
text = text[token_size:]
@selenium_action
def send_ctrl_something(self, webelement, key):
return webelement.send_keys(Keys.CONTROL, self.key)
# start xpath with ".//"
@selenium_action
def find_child_elements(self, webelement, identifier):
elements = None
try:
elements = webelement.find_elements(
by=self.identifier.by,
value=self.identifier.identifier)
except BaseException:
return None
if elements is not None:
ret = []
for child_element in elements:
ret.append(SeleniumElement.by_element(child_element,
self.session))
return ret
def drag_and_drop(self, target):
if self.is_visible() and target.is_visible():
elem_from = self.get_element()
elem_to = target.get_element()
if elem_from and elem_to:
webdriver.ActionChains(
browser.get_browser().get_driver().drag_and_drop(
elem_from, elem_to).perform())
else:
logger.error("drag-n-drop: items are not visible")
@selenium_action
def deselect_all(self, webelement):
sel = select.Select(webelement)
return sel.deselect_all()
@selenium_action
def select_by_index(self, webelement, index):
sel = select.Select(webelement)
return sel.select_by_index(index)
@selenium_action
def select_by_value(self, webelement, value):
sel = select.Select(webelement)
return sel.select_by_value(value)
@selenium_action
def select_by_visible_text(self, webelement, value):
sel = select.Select(webelement)
return sel.select_by_visible_text(value)
@selenium_action
def deselect_by_index(self, webelement, index):
sel = select.Select(webelement)
return sel.deselect_by_index(index)
@selenium_action
def deselect_by_value(self, webelement, value):
sel = select.Select(webelement)
return sel.deselect_by_value(value)
@selenium_action
def deselect_by_visible_text(self, webelement, value):
sel = select.Select(webelement)
return sel.deselect_by_visible_text(value)
def selenium_elements(identifier):
elements = []
current_browser = browser.get_browser()
browser_session = current_browser.get_session()
results = current_browser.get_driver().find_elements(
by=identifier.by, value=identifier.identifier)
for elem in results:
elements.append(SeleniumElement.by_element(elem, browser_session))
return elements

View File

@ -0,0 +1,51 @@
from functools import wraps
from selenium.common.exceptions import (
StaleElementReferenceException, InvalidElementStateException
)
import logging
logger = logging.getLogger(__name__)
def selenium_action(f):
"""The selenium_action decorator
This decorator can be used to wrap methods of ``SeleniumElement`` to
provide exception handling, and automatic retries.
It expects that the method being decorated accepts a ``webelement``
as its first positional argument after ``self``.
E.g.
@selenium_action
def click_button(self, webelement):
...
"""
@wraps(f)
def wrapper(self, *args, **kwargs):
webelement = self.get_element()
if not webelement:
return
try:
try:
return f(self, webelement, *args, **kwargs)
except (StaleElementReferenceException,
InvalidElementStateException):
logger.debug("stale element: {}".format(
self.get_identifier_string()))
self.wait_for_element(3)
webelement = self.get_element()
if webelement is None:
return None
return f(self, webelement, *args, **kwargs)
except Exception as e:
logger.warning("selenium exception on '{}': {}"
.format(self.get_identifier_string(), e))
raise
return None
return wrapper

View File

View File

@ -0,0 +1,109 @@
from tempest_tripleo_ui.widgets import login_page
from tempest_tripleo_ui import alerts
from tempest_tripleo_ui.timer import Timer
from time import sleep
from tempest import config
import logging
CONF = config.CONF
logger = logging.getLogger(__name__)
def logout():
logger.info("Logging out...")
if not is_logged_in():
logger.warning("Hey! Was already logged out...")
return
while alerts.clear_danger_message(timeout=1) or \
alerts.clear_success_message(timeout=1):
pass
# First, we need to click on the toggle to open the user dropdown menu
if login_page.user_toggle_button.wait_for_element(0):
if not login_page.user_toggle_button.click():
logger.error("click failed on the 'user toggle' button")
if not login_page.logout_button.click():
logger.error("click failed on the 'Logout' button")
# FIXME: this should be an assertion
if not login_page.password.wait_for_element(5):
logger.error("Logout failed (didn't see the login screen after 5 "
"seconds)")
return False
return True
def is_logged_in():
for _retries in range(0, 5):
# find the logout button or the password field,
# whichever comes first
if login_page.logout_button.wait_for_element(1):
return True
if login_page.password.wait_for_element(1):
return False
logger.error("login.is_logged_in(): could not determine if "
"logged in or not, no recognizable elements found "
"on the page within 10 seconds")
return False
def login(username=None, password=None):
logger.info("Logging in...")
if is_logged_in():
logger.error("Already logged in... Make sure "
"you log out first!")
return False
if username is None:
username = CONF.auth.admin_username
if password is None:
password = CONF.auth.admin_password
if username is None or password is None:
logger.error("Missing username or password. Make sure they are "
"configured in the tempest conf file")
return False
login_page.username.clear()
login_page.username.send_keys(username)
login_page.password.clear()
login_page.password.send_keys(password)
login_page.password.submit()
for _retry in range(6):
if not login_page.logout_button.wait_for_element():
if login_page.unauthorized_div.wait_for_element(1):
logger.warning(login_page.unauthorized_div.get_all_text())
return False
if not login_page.logout_button.wait_for_element(0):
logger.error(
"Login failed (waited 60 seconds and no sign of "
"a logout button...)")
return False
# wait for the modal div that covers the page to go away...
timeout = 20
timer = Timer("wait for modal after login", timeout=timeout)
while login_page.modal_div.wait_for_element(1) and \
timer.get_duration() < timeout:
sleep(1)
if not timer.assert_timer():
logger.error("exceeded timer")
return False
if timer.get_duration() > 5:
logger.warning("login took {} seconds".format(
timer.get_duration()))
# wait for all spinners
alerts.wait_for_spinner()
# clear all alerts that sometimes appear straight after login
while alerts.clear_danger_message():
pass
return True

View File

@ -13,29 +13,13 @@
# License for the specific language governing permissions and limitations
# under the License.
from six.moves.urllib import request
from tempest import config
from tempest import test
from tempest_tripleo_ui.base import GUITestCase
CONF = config.CONF
class TestBasic(test.BaseTestCase):
class TestBasic(GUITestCase):
"""Checks that the UI is available"""
opener = None
def check_if_ui_accessible(self):
response = self._get_opener().open(CONF.tripleo_ui.url).read()
self.assertIn('tripleo_ui_config.js', response)
def _get_opener(self):
if not self.opener:
self.opener = request.build_opener(request.HTTPCookieProcessor())
return self.opener
def test_basic_scenario(self):
self.check_if_ui_accessible()
def test_basic_plugin_functionality(self):
pass

View File

@ -0,0 +1,40 @@
from time import time
import logging
logger = logging.getLogger(__name__)
class Timer(object):
def __init__(self, operation_name, timeout=10):
self.timer = time()
self.operation_name = operation_name
self.timeout = timeout
self.duration = None
def assert_timer(self, message=None):
if self.timer is None:
logger.error("trying to use the same timer twice - please "
"create a new timer to start a new measurement")
return
self.duration = time() - self.timer
self.timer = None
if self.duration > self.timeout:
err_msg = ("operation '{}' took {} seconds, allowed timeout "
"is {} seconds").format(self.operation_name,
self.duration,
self.timeout)
if message is not None:
err_msg += ": {}".format(message)
logger.error(err_msg)
return False
return True
def get_duration(self):
if self.duration is not None:
return self.duration
return time() - self.timer

View File

View File

@ -0,0 +1,13 @@
from tempest_tripleo_ui.core import SeleniumElement
username = SeleniumElement.by_id("username")
password = SeleniumElement.by_id("password")
logout_button = SeleniumElement.by_id('NavBar__logoutLink')
user_toggle_button = SeleniumElement.by_id('UserDropdown__toggle')
unauthorized_div = SeleniumElement.by_xpath(
'//div[contains(@class, "login")]/div[contains'
'(@class, "alert-danger")]')
modal_div = SeleniumElement.by_xpath(
'//div[contains(@class, "modal")][contains(@role, "dialog")]')