
We have a place in the form-handling code where we look for the blue form submit button, and if it's not found, we look for the red form submit button. It looks correct at first glance, however it takes 10 seconds to throw NoSuchElementException, because there is a delay that makes sure that the page loads fully before raising that exception. Replacing this with a selector that finds both the blue and the red buttons in one query speeds the whole integration tests suite by half an hour, because there is no more waiting on every delete confirmation dialog. Change-Id: Ie256118c0a84a8868393018b25b0aa049582a17d
630 lines
21 KiB
Python
630 lines
21 KiB
Python
# 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 collections
|
|
import os
|
|
|
|
from django.utils import html
|
|
from selenium.common import exceptions
|
|
from selenium.webdriver.common import by
|
|
import selenium.webdriver.support.ui as Support
|
|
|
|
from openstack_dashboard.test.integration_tests.regions import baseregion
|
|
from openstack_dashboard.test.integration_tests.regions import menus
|
|
|
|
|
|
class FieldFactory(baseregion.BaseRegion):
|
|
"""Factory for creating form field objects."""
|
|
|
|
FORM_FIELDS_TYPES = set()
|
|
_element_locator_str_prefix = 'div.form-group'
|
|
|
|
def __init__(self, driver, conf, src_elem=None):
|
|
super().__init__(driver, conf, src_elem)
|
|
|
|
def fields(self):
|
|
for field_cls in self.FORM_FIELDS_TYPES:
|
|
locator = (by.By.CSS_SELECTOR,
|
|
'%s %s' % (self._element_locator_str_prefix,
|
|
field_cls._element_locator_str_suffix))
|
|
elements = super()._get_elements(*locator)
|
|
for element in elements:
|
|
yield field_cls(self.driver, self.conf, src_elem=element)
|
|
|
|
@classmethod
|
|
def register_field_cls(cls, field_class, base_classes=None):
|
|
"""Register new field class.
|
|
|
|
Add new field class and remove all base classes from the set of
|
|
registered classes as they should not be in.
|
|
"""
|
|
cls.FORM_FIELDS_TYPES.add(field_class)
|
|
cls.FORM_FIELDS_TYPES -= set(base_classes)
|
|
|
|
|
|
class MetaBaseFormFieldRegion(type):
|
|
"""Register form field class in FieldFactory."""
|
|
def __init__(cls, name, bases, dct):
|
|
FieldFactory.register_field_cls(cls, bases)
|
|
super().__init__(name, bases, dct)
|
|
|
|
|
|
class BaseFormFieldRegion(baseregion.BaseRegion,
|
|
metaclass=MetaBaseFormFieldRegion):
|
|
"""Base class for form fields classes."""
|
|
|
|
_label_locator = None
|
|
_element_locator = None
|
|
|
|
@property
|
|
def label(self):
|
|
return self._get_element(*self._label_locator)
|
|
|
|
@property
|
|
def element(self):
|
|
return self.src_elem
|
|
|
|
@property
|
|
def name(self):
|
|
return self.element.get_attribute('name')
|
|
|
|
@property
|
|
def id(self):
|
|
return self.element.get_attribute('id')
|
|
|
|
def is_required(self):
|
|
classes = self.driver.get_attribute('class')
|
|
return 'required' in classes
|
|
|
|
def is_displayed(self):
|
|
return self.element.is_displayed()
|
|
|
|
|
|
class CheckBoxMixin(object):
|
|
@property
|
|
def label(self):
|
|
id_attribute = self.element.get_attribute('id')
|
|
return self.element.find_element(
|
|
by.By.XPATH, '../..//label[@for="{}"]'.format(id_attribute))
|
|
|
|
def is_marked(self):
|
|
return self.element.is_selected()
|
|
|
|
def mark(self):
|
|
if not self.is_marked():
|
|
self.label.click()
|
|
|
|
def unmark(self):
|
|
if self.is_marked():
|
|
self.label.click()
|
|
|
|
|
|
class CheckBoxFormFieldRegion(CheckBoxMixin, BaseFormFieldRegion):
|
|
"""Checkbox field."""
|
|
|
|
_element_locator_str_suffix = 'input[type=checkbox]'
|
|
|
|
|
|
class ChooseFileFormFieldRegion(BaseFormFieldRegion):
|
|
"""Choose file field."""
|
|
|
|
_element_locator_str_suffix = 'input[type=file]'
|
|
|
|
def choose(self, path):
|
|
self.element.send_keys(os.path.join(os.getcwd(), path))
|
|
|
|
|
|
class BaseTextFormFieldRegion(BaseFormFieldRegion):
|
|
|
|
_element_locator = None
|
|
|
|
@property
|
|
def text(self):
|
|
return self.element.text
|
|
|
|
@text.setter
|
|
def text(self, text):
|
|
self._fill_field_element(text, self.element)
|
|
|
|
|
|
class TextInputFormFieldRegion(BaseTextFormFieldRegion):
|
|
"""Text input box."""
|
|
|
|
_element_locator_str_suffix = \
|
|
'input[type=text], input[type=None]'
|
|
|
|
|
|
class PasswordInputFormFieldRegion(BaseTextFormFieldRegion):
|
|
"""Password text input box."""
|
|
|
|
_element_locator_str_suffix = 'input[type=password]'
|
|
|
|
|
|
class EmailInputFormFieldRegion(BaseTextFormFieldRegion):
|
|
"""Email text input box."""
|
|
|
|
_element_locator_str_suffix = 'input[type=email]'
|
|
|
|
|
|
class TextAreaFormFieldRegion(BaseTextFormFieldRegion):
|
|
"""Multi-line text input box."""
|
|
|
|
_element_locator_str_suffix = 'textarea'
|
|
|
|
|
|
class IntegerFormFieldRegion(BaseFormFieldRegion):
|
|
"""Integer input box."""
|
|
|
|
_element_locator_str_suffix = 'input[type=number]'
|
|
|
|
@property
|
|
def value(self):
|
|
return self.element.get_attribute("value")
|
|
|
|
@value.setter
|
|
def value(self, value):
|
|
self._fill_field_element(value, self.element)
|
|
|
|
|
|
class SelectFormFieldRegion(BaseFormFieldRegion):
|
|
"""Select box field."""
|
|
|
|
_element_locator_str_suffix = 'select.form-control'
|
|
|
|
def is_displayed(self):
|
|
return self.element._el.is_displayed()
|
|
|
|
@property
|
|
def element(self):
|
|
return Support.Select(self.src_elem)
|
|
|
|
@property
|
|
def values(self):
|
|
results = []
|
|
for option in self.element.all_selected_options:
|
|
results.append(option.get_attribute('value'))
|
|
return results
|
|
|
|
@property
|
|
def options(self):
|
|
results = collections.OrderedDict()
|
|
for option in self.element.options:
|
|
results[option.get_attribute('value')] = option.text
|
|
return results
|
|
|
|
@property
|
|
def name(self):
|
|
return self.element._el.get_attribute('name')
|
|
|
|
@property
|
|
def id(self):
|
|
return self.element._el.get_attribute('id')
|
|
|
|
@property
|
|
def text(self):
|
|
return self.element.first_selected_option.text
|
|
|
|
@text.setter
|
|
def text(self, text):
|
|
js_cmd = ("$('select option').filter(function() {return $(this).text()"
|
|
" == \"%s\";}).prop('selected', true); $('[name=\"%s\"]')."
|
|
"change();" % (html.escape(text), self.name))
|
|
self.driver.execute_script(js_cmd)
|
|
|
|
@property
|
|
def value(self):
|
|
return self.element.first_selected_option.get_attribute('value')
|
|
|
|
@value.setter
|
|
def value(self, value):
|
|
js_cmd = "$('[name=\"%s\"]').val(\"%s\").change();" % (
|
|
self.name, html.escape(value))
|
|
self.driver.execute_script(js_cmd)
|
|
|
|
|
|
class ThemableSelectFormFieldRegion(BaseFormFieldRegion):
|
|
"""Select box field."""
|
|
|
|
_element_locator_str_suffix = '.themable-select'
|
|
_raw_select_locator = (by.By.CSS_SELECTOR, 'select')
|
|
_selected_label_locator = (by.By.CSS_SELECTOR, '.dropdown-title')
|
|
_dropdown_menu_locator = (by.By.CSS_SELECTOR, 'ul.dropdown-menu > li > a')
|
|
|
|
def __init__(self, driver, conf, strict_options_match=True, **kwargs):
|
|
super().__init__(driver, conf, **kwargs)
|
|
self.strict_options_match = strict_options_match
|
|
|
|
@property
|
|
def hidden_element(self):
|
|
elem = self._get_element(*self._raw_select_locator)
|
|
return SelectFormFieldRegion(self.driver, self.conf, src_elem=elem)
|
|
|
|
@property
|
|
def name(self):
|
|
return self.hidden_element.name
|
|
|
|
@property
|
|
def text(self):
|
|
return self._get_element(*self._selected_label_locator).text.strip()
|
|
|
|
@property
|
|
def value(self):
|
|
return self.hidden_element.value
|
|
|
|
@property
|
|
def options(self):
|
|
return self._get_elements(*self._dropdown_menu_locator)
|
|
|
|
@text.setter
|
|
def text(self, text):
|
|
if text != self.text:
|
|
js_cmd = ("$('select[name=\"%s\"]').closest(\"div\")."
|
|
"find(\".btn\").click();" % (self.name))
|
|
self.driver.execute_script(js_cmd)
|
|
for option in self.options:
|
|
if self.strict_options_match:
|
|
match = text == str(option.text.strip())
|
|
else:
|
|
match = text in str(option.text.strip())
|
|
if match:
|
|
option.click()
|
|
return
|
|
raise ValueError(
|
|
'Widget "%s" does not have an option with text "%s"' %
|
|
(self.name, text))
|
|
|
|
@value.setter
|
|
def value(self, value):
|
|
if value != self.value:
|
|
self.src_elem.click()
|
|
for option in self.options:
|
|
if value == option.get_attribute('data-select-value'):
|
|
option.click()
|
|
return
|
|
raise ValueError(
|
|
'Widget "%s" does not have an option with value "%s"' %
|
|
(self.name, value))
|
|
|
|
|
|
class BaseFormRegion(baseregion.BaseRegion):
|
|
"""Base class for forms."""
|
|
|
|
_submit_locator = (by.By.CSS_SELECTOR, '*.btn.btn-primary,*.btn.btn-danger')
|
|
_cancel_locator = (by.By.CSS_SELECTOR, '*.btn.cancel')
|
|
_default_form_locator = (by.By.CSS_SELECTOR, 'div.modal-dialog')
|
|
|
|
def __init__(self, driver, conf, src_elem=None):
|
|
# In most cases forms can be located through _default_form_locator,
|
|
# so specifying source element can be skipped.
|
|
if src_elem is None:
|
|
# fake self.src_elem must be set up in order self._get_element work
|
|
self.src_elem = driver
|
|
# bind the topmost modal form in a modal stack
|
|
src_elem = self._get_elements(*self._default_form_locator)[-1]
|
|
super().__init__(driver, conf, src_elem)
|
|
|
|
@property
|
|
def _submit_element(self):
|
|
submit_element = self._get_element(*self._submit_locator)
|
|
return submit_element
|
|
|
|
def submit(self):
|
|
self._submit_element.click()
|
|
self.wait_till_spinner_disappears()
|
|
|
|
@property
|
|
def _cancel_element(self):
|
|
return self._get_element(*self._cancel_locator)
|
|
|
|
def cancel(self):
|
|
self._cancel_element.click()
|
|
|
|
|
|
class FormRegion(BaseFormRegion):
|
|
"""Standard form."""
|
|
|
|
_header_locator = (by.By.CSS_SELECTOR, 'div.modal-header > h3')
|
|
_side_info_locator = (by.By.CSS_SELECTOR, 'div.right')
|
|
_fields_locator = (by.By.CSS_SELECTOR, 'fieldset')
|
|
_step_locator = (by.By.CSS_SELECTOR, 'div.step')
|
|
|
|
# private methods
|
|
def __init__(self, driver, conf, src_elem=None, field_mappings=None):
|
|
super().__init__(driver, conf, src_elem)
|
|
self.field_mappings = self._prepare_mappings(field_mappings)
|
|
self.wait_till_spinner_disappears()
|
|
self._init_form_fields()
|
|
|
|
# protected methods
|
|
|
|
# NOTE: There is a case where a subclass accepts different field_mappings.
|
|
# In such case, this method should be overridden.
|
|
def _prepare_mappings(self, field_mappings):
|
|
return self._format_mappings(field_mappings)
|
|
|
|
@staticmethod
|
|
def _format_mappings(field_mappings):
|
|
if isinstance(field_mappings, tuple):
|
|
return {item: item for item in field_mappings}
|
|
else:
|
|
return field_mappings
|
|
|
|
def _init_form_fields(self):
|
|
self.fields_src_elem = self._get_element(*self._fields_locator)
|
|
fields = self._get_form_fields()
|
|
for accessor_name, accessor_expr in self.field_mappings.items():
|
|
if isinstance(accessor_expr, str):
|
|
self._dynamic_properties[accessor_name] = fields[accessor_expr]
|
|
else: # it is a class
|
|
self._dynamic_properties[accessor_name] = accessor_expr(
|
|
self.driver, self.conf)
|
|
|
|
def _get_form_fields(self):
|
|
factory = FieldFactory(self.driver, self.conf, self.fields_src_elem)
|
|
fields = {}
|
|
try:
|
|
self._turn_off_implicit_wait()
|
|
for field in factory.fields():
|
|
if hasattr(field, 'name') and field.name is not None:
|
|
fields.update({field.name.replace('-', '_'): field})
|
|
elif hasattr(field, 'id') and field.id is not None:
|
|
fields.update({field.id.replace('-', '_'): field})
|
|
return fields
|
|
finally:
|
|
self._turn_on_implicit_wait()
|
|
|
|
def set_field_values(self, data):
|
|
"""Set fields values
|
|
|
|
data - {field_name: field_value, field_name: field_value ...}
|
|
"""
|
|
for field_name in data:
|
|
field = getattr(self, field_name, None)
|
|
# Field form does not exist
|
|
if field is None:
|
|
raise AttributeError("Unknown form field name.")
|
|
value = data[field_name]
|
|
# if None - default value is left in field
|
|
if value is not None:
|
|
# all text fields
|
|
if hasattr(field, "text"):
|
|
field.text = value
|
|
# file upload field
|
|
elif hasattr(field, "path"):
|
|
field.path = value
|
|
# integers fields
|
|
elif hasattr(field, "value"):
|
|
field.value = value
|
|
|
|
# properties
|
|
@property
|
|
def header(self):
|
|
"""Form header."""
|
|
return self._get_element(*self._header_locator)
|
|
|
|
@property
|
|
def sideinfo(self):
|
|
"""Right part of form, usually contains description."""
|
|
return self._get_element(*self._side_info_locator)
|
|
|
|
@property
|
|
def fields(self):
|
|
"""List of all fields that form contains."""
|
|
return self._get_form_fields()
|
|
|
|
|
|
class TabbedFormRegion(FormRegion):
|
|
"""Forms that are divided with tabs.
|
|
|
|
As example is taken form under the
|
|
Project/Network/Networks/Create Network, on initialization form needs
|
|
to have form field names divided into tuples, that represents the tabs
|
|
and the fields located under them.
|
|
|
|
Usage:
|
|
|
|
form_field_names = (("network_name", "admin_state"),
|
|
("create_subnet", "subnet_name", "network_address",
|
|
"ip_version", "gateway_ip", "disable_gateway"),
|
|
("enable_dhcp", "allocation_pools", "dns_name_servers",
|
|
"host_routes"))
|
|
form = TabbedFormRegion(self.conf, self.driver, None, form_field_names)
|
|
form.network_name.text = "test_network_name"
|
|
"""
|
|
|
|
_submit_locator = (by.By.CSS_SELECTOR, '*.btn.btn-primary[type=submit]')
|
|
_side_info_locator = (by.By.CSS_SELECTOR, "td.help_text")
|
|
|
|
def __init__(self, driver, conf, field_mappings=None, default_tab=0):
|
|
self.current_tab = default_tab
|
|
super().__init__(driver, conf, field_mappings=field_mappings)
|
|
|
|
def _prepare_mappings(self, field_mappings):
|
|
return [
|
|
self._format_mappings(tab_mappings)
|
|
for tab_mappings in field_mappings
|
|
]
|
|
|
|
def _init_form_fields(self):
|
|
self.switch_to(self.current_tab)
|
|
|
|
def _init_tab_fields(self, tab_index):
|
|
fieldsets = self._get_elements(*self._fields_locator)
|
|
self.fields_src_elem = fieldsets[tab_index]
|
|
fields = self._get_form_fields()
|
|
current_tab_mappings = self.field_mappings[tab_index]
|
|
for accessor_name, accessor_expr in current_tab_mappings.items():
|
|
if isinstance(accessor_expr, str):
|
|
self._dynamic_properties[accessor_name] = fields[accessor_expr]
|
|
else: # it is a class
|
|
self._dynamic_properties[accessor_name] = accessor_expr(
|
|
self.driver, self.conf)
|
|
|
|
def switch_to(self, tab_index=0):
|
|
self.tabs.switch_to(index=tab_index)
|
|
self._init_tab_fields(tab_index)
|
|
|
|
@property
|
|
def tabs(self):
|
|
return menus.TabbedMenuRegion(self.driver,
|
|
self.conf,
|
|
src_elem=self.src_elem)
|
|
|
|
|
|
class WizardFormRegion(FormRegion):
|
|
"""Form consists of sequence of steps."""
|
|
|
|
_submit_locator = (by.By.CSS_SELECTOR,
|
|
'*.btn.btn-primary.finish[type=button]')
|
|
|
|
def __init__(self, driver, conf, field_mappings=None, default_step=0):
|
|
self.current_step = default_step
|
|
super().__init__(driver, conf, field_mappings=field_mappings)
|
|
|
|
def _form_getter(self):
|
|
return self.driver.find_element(*self._default_form_locator)
|
|
|
|
def _prepare_mappings(self, field_mappings):
|
|
return [
|
|
self._format_mappings(step_mappings)
|
|
for step_mappings in field_mappings
|
|
]
|
|
|
|
def _init_form_fields(self):
|
|
self.switch_to(self.current_step)
|
|
|
|
def _init_step_fields(self, step_index):
|
|
steps = self._get_elements(*self._step_locator)
|
|
self.fields_src_elem = steps[step_index]
|
|
fields = self._get_form_fields()
|
|
current_step_mappings = self.field_mappings[step_index]
|
|
for accessor_name, accessor_expr in current_step_mappings.items():
|
|
if isinstance(accessor_expr, str):
|
|
self._dynamic_properties[accessor_name] = fields[accessor_expr]
|
|
else: # it is a class
|
|
self._dynamic_properties[accessor_name] = accessor_expr(
|
|
self.driver, self.conf)
|
|
|
|
def switch_to(self, step_index=0):
|
|
self.steps.switch_to(index=step_index)
|
|
self._init_step_fields(step_index)
|
|
|
|
def wait_till_wizard_disappears(self):
|
|
try:
|
|
self.wait_till_element_disappears(self._form_getter)
|
|
except exceptions.StaleElementReferenceException:
|
|
# The form might be absent already by the time the first check
|
|
# occurs. So just suppress the exception here.
|
|
pass
|
|
|
|
@property
|
|
def steps(self):
|
|
return menus.WizardMenuRegion(self.driver,
|
|
self.conf,
|
|
src_elem=self.src_elem)
|
|
|
|
|
|
class DateFormRegion(BaseFormRegion):
|
|
"""Form that queries data to table that is regularly below the form.
|
|
|
|
A typical example is located on Project/Compute/Overview page.
|
|
"""
|
|
|
|
_from_field_locator = (by.By.CSS_SELECTOR, 'input#id_start')
|
|
_to_field_locator = (by.By.CSS_SELECTOR, 'input#id_end')
|
|
|
|
@property
|
|
def from_date(self):
|
|
return self._get_element(*self._from_field_locator)
|
|
|
|
@property
|
|
def to_date(self):
|
|
return self._get_element(*self._to_field_locator)
|
|
|
|
def query(self, start, end):
|
|
self._set_from_field(start)
|
|
self._set_to_field(end)
|
|
self.submit()
|
|
|
|
def _set_from_field(self, value):
|
|
self._fill_field_element(value, self.from_date)
|
|
|
|
def _set_to_field(self, value):
|
|
self._fill_field_element(value, self.to_date)
|
|
|
|
|
|
class MetadataFormRegion(BaseFormRegion):
|
|
|
|
_input_fields = (by.By.CSS_SELECTOR, 'div.input-group')
|
|
_custom_input_field = (by.By.XPATH, "//input[@name='customItem']")
|
|
_custom_input_button = (by.By.CSS_SELECTOR, 'span.input-group-btn > .btn')
|
|
_submit_locator = (by.By.CSS_SELECTOR, '.modal-footer > .btn.btn-primary')
|
|
_cancel_locator = (by.By.CSS_SELECTOR, '.modal-footer > .btn.btn-default')
|
|
|
|
def _form_getter(self):
|
|
return self.driver.find_element(*self._default_form_locator)
|
|
|
|
@property
|
|
def custom_field_value(self):
|
|
return self._get_element(*self._custom_input_field)
|
|
|
|
@property
|
|
def add_button(self):
|
|
return self._get_element(*self._custom_input_button)
|
|
|
|
def add_custom_field(self, field_name, field_value):
|
|
self.custom_field_value.send_keys(field_name)
|
|
self.add_button.click()
|
|
for div in self._get_elements(*self._input_fields):
|
|
if div.text in field_name:
|
|
field = div.find_element(by.By.CSS_SELECTOR, 'input')
|
|
if not hasattr(self, field_name):
|
|
self._dynamic_properties[field_name] = field
|
|
self.set_field_value(field_name, field_value)
|
|
|
|
def set_field_value(self, field_name, field_value):
|
|
if hasattr(self, field_name):
|
|
field = getattr(self, field_name)
|
|
field.send_keys(field_value)
|
|
else:
|
|
raise AttributeError("Unknown form field '{}'.".format(field_name))
|
|
|
|
def wait_till_spinner_disappears(self):
|
|
# No spinner is invoked after the 'Save' button click
|
|
# Will wait till the form itself disappears
|
|
try:
|
|
self.wait_till_element_disappears(self._form_getter)
|
|
except exceptions.StaleElementReferenceException:
|
|
# The form might be absent already by the time the first check
|
|
# occurs. So just suppress the exception here.
|
|
pass
|
|
|
|
|
|
class ItemTextDescription(baseregion.BaseRegion):
|
|
|
|
_separator_locator = (by.By.CSS_SELECTOR, 'dl.dl-horizontal')
|
|
_key_locator = (by.By.CSS_SELECTOR, 'dt')
|
|
_value_locator = (by.By.CSS_SELECTOR, 'dd')
|
|
|
|
def __init__(self, driver, conf, src=None):
|
|
super().__init__(driver, conf, src)
|
|
|
|
def get_content(self):
|
|
keys = []
|
|
values = []
|
|
for section in self._get_elements(*self._separator_locator):
|
|
keys.extend(
|
|
[x.text for x in section.find_elements(*self._key_locator)])
|
|
values.extend(
|
|
[x.text for x in section.find_elements(*self._value_locator)])
|
|
return dict(zip(keys, values))
|