From 83117a21845e8b94939ea89b33df9287bd9fc8cf Mon Sep 17 00:00:00 2001 From: Owen McGonagle Date: Tue, 28 Oct 2025 13:41:04 -0400 Subject: [PATCH] Revert "Remove all dependencies/connections of old integration test code" This reverts commit 49e5fe185a915ab80ccf4c130225371ade323711. reason: - these changes are breaking the Manila UI plugin job - there is a good chance other Horizon plugin jobs would also fail. - more testing across as many plugin jobs will be needed before a revisit. Change-Id: I76018c5eff71ed82b89e202957ab3f6ee9452369 Signed-off-by: Owen McGonagle --- .../templates/horizon/_scripts.html | 3 + .../test/integration_tests/README.rst | 31 + .../test/integration_tests/basewebobject.py | 169 +++++ .../test/integration_tests/decorators.py | 176 +++++ .../test/integration_tests/helpers.py | 355 ++++++++++ .../test/integration_tests/pages/__init__.py | 0 .../integration_tests/pages/admin/__init__.py | 0 .../pages/admin/compute/__init__.py | 0 .../pages/admin/compute/flavorspage.py | 155 +++++ .../pages/admin/compute/hostaggregatespage.py | 79 +++ .../pages/admin/compute/hypervisorspage.py | 20 + .../pages/admin/compute/imagespage.py | 18 + .../pages/admin/compute/instancespage.py | 19 + .../pages/admin/network/__init__.py | 0 .../pages/admin/network/floatingipspage.py | 18 + .../pages/admin/network/networkspage.py | 38 + .../pages/admin/network/routerspage.py | 41 ++ .../pages/admin/overviewpage.py | 19 + .../pages/admin/system/__init__.py | 0 .../pages/admin/system/defaultspage.py | 166 +++++ .../pages/admin/system/imagespage.py | 17 + .../admin/system/metadatadefinitionspage.py | 128 ++++ .../admin/system/resource_usage/__init__.py | 0 .../admin/system/system_info/__init__.py | 0 .../pages/admin/volume/__init__.py | 0 .../pages/admin/volume/grouptypespage.py | 70 ++ .../pages/admin/volume/snapshotspage.py | 18 + .../pages/admin/volume/volumespage.py | 18 + .../pages/admin/volume/volumetypespage.py | 135 ++++ .../test/integration_tests/pages/basepage.py | 89 +++ .../pages/identity/__init__.py | 0 .../pages/identity/groupspage.py | 84 +++ .../pages/identity/projectspage.py | 100 +++ .../pages/identity/rolespage.py | 20 + .../pages/identity/userspage.py | 69 ++ .../test/integration_tests/pages/loginpage.py | 99 +++ .../integration_tests/pages/navigation.py | 341 +++++++++ .../integration_tests/pages/pageobject.py | 94 +++ .../pages/project/__init__.py | 0 .../pages/project/apiaccesspage.py | 81 +++ .../pages/project/compute/__init__.py | 0 .../pages/project/compute/imagespage.py | 314 +++++++++ .../pages/project/compute/instancespage.py | 203 ++++++ .../pages/project/compute/keypairspage.py | 82 +++ .../pages/project/compute/overviewpage.py | 37 + .../pages/project/compute/servergroupspage.py | 20 + .../pages/project/network/__init__.py | 0 .../pages/project/network/floatingipspage.py | 107 +++ .../project/network/networkoverviewpage.py | 37 + .../pages/project/network/networkspage.py | 124 ++++ .../project/network/networktopologypage.py | 20 + .../project/network/routerinterfacespage.py | 111 +++ .../project/network/routeroverviewpage.py | 42 ++ .../pages/project/network/routerspage.py | 149 ++++ .../network/security_groups/__init__.py | 0 .../security_groups/managerulespage.py | 74 ++ .../project/network/securitygroupspage.py | 79 +++ .../pages/project/object_store/__init__.py | 0 .../pages/project/volumes/__init__.py | 0 .../pages/project/volumes/snapshotspage.py | 133 ++++ .../pages/project/volumes/volumespage.py | 275 ++++++++ .../pages/settings/__init__.py | 0 .../pages/settings/changepasswordpage.py | 52 ++ .../pages/settings/usersettingspage.py | 83 +++ .../integration_tests/regions/__init__.py | 0 .../test/integration_tests/regions/bars.py | 61 ++ .../integration_tests/regions/baseregion.py | 64 ++ .../integration_tests/regions/exceptions.py | 22 + .../test/integration_tests/regions/forms.py | 652 ++++++++++++++++++ .../test/integration_tests/regions/menus.py | 468 +++++++++++++ .../integration_tests/regions/messages.py | 47 ++ .../test/integration_tests/regions/tables.py | 520 ++++++++++++++ .../test/integration_tests/tests/__init__.py | 0 .../tests/test-data/empty_namespace.json | 21 + .../tests/test_credentials.py | 61 ++ .../integration_tests/tests/test_defaults.py | 71 ++ .../integration_tests/tests/test_flavors.py | 75 ++ .../tests/test_floatingips.py | 79 +++ .../integration_tests/tests/test_groups.py | 60 ++ .../tests/test_grouptypes.py | 43 ++ .../tests/test_host_aggregates.py | 46 ++ .../integration_tests/tests/test_images.py | 384 +++++++++++ .../integration_tests/tests/test_instances.py | 401 +++++++++++ .../integration_tests/tests/test_keypairs.py | 40 ++ .../integration_tests/tests/test_login.py | 31 + .../tests/test_metadata_definitions.py | 162 +++++ .../integration_tests/tests/test_networks.py | 162 +++++ .../integration_tests/tests/test_projects.py | 67 ++ .../integration_tests/tests/test_router.py | 210 ++++++ .../tests/test_router_gateway.py | 66 ++ .../tests/test_security_groups.py | 118 ++++ .../tests/test_user_settings.py | 163 +++++ .../integration_tests/tests/test_users.py | 33 + .../tests/test_volume_snapshots.py | 282 ++++++++ .../integration_tests/tests/test_volumes.py | 344 +++++++++ .../tests/test_volumetypes.py | 114 +++ .../test/integration_tests/video_recorder.py | 84 +++ ...ve-legacy-integration-tests-82401b61d.yaml | 8 - tools/executable_files.txt | 2 +- tox.ini | 13 + 100 files changed, 9577 insertions(+), 9 deletions(-) create mode 100644 openstack_dashboard/test/integration_tests/README.rst create mode 100644 openstack_dashboard/test/integration_tests/basewebobject.py create mode 100644 openstack_dashboard/test/integration_tests/decorators.py create mode 100644 openstack_dashboard/test/integration_tests/helpers.py create mode 100644 openstack_dashboard/test/integration_tests/pages/__init__.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/__init__.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/compute/__init__.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/compute/flavorspage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/compute/hostaggregatespage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/compute/hypervisorspage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/compute/imagespage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/compute/instancespage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/network/__init__.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/network/floatingipspage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/network/networkspage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/network/routerspage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/overviewpage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/system/__init__.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/system/defaultspage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/system/imagespage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/system/metadatadefinitionspage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/system/resource_usage/__init__.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/system/system_info/__init__.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/volume/__init__.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/volume/grouptypespage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/volume/snapshotspage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/volume/volumespage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/admin/volume/volumetypespage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/basepage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/identity/__init__.py create mode 100644 openstack_dashboard/test/integration_tests/pages/identity/groupspage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/identity/projectspage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/identity/rolespage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/identity/userspage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/loginpage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/navigation.py create mode 100644 openstack_dashboard/test/integration_tests/pages/pageobject.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/__init__.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/apiaccesspage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/compute/__init__.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/compute/imagespage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/compute/instancespage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/compute/keypairspage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/compute/overviewpage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/compute/servergroupspage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/network/__init__.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/network/floatingipspage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/network/networkoverviewpage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/network/networkspage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/network/networktopologypage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/network/routerinterfacespage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/network/routeroverviewpage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/network/routerspage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/network/security_groups/__init__.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/network/security_groups/managerulespage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/network/securitygroupspage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/object_store/__init__.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/volumes/__init__.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/volumes/snapshotspage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/project/volumes/volumespage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/settings/__init__.py create mode 100644 openstack_dashboard/test/integration_tests/pages/settings/changepasswordpage.py create mode 100644 openstack_dashboard/test/integration_tests/pages/settings/usersettingspage.py create mode 100644 openstack_dashboard/test/integration_tests/regions/__init__.py create mode 100644 openstack_dashboard/test/integration_tests/regions/bars.py create mode 100644 openstack_dashboard/test/integration_tests/regions/baseregion.py create mode 100644 openstack_dashboard/test/integration_tests/regions/exceptions.py create mode 100644 openstack_dashboard/test/integration_tests/regions/forms.py create mode 100644 openstack_dashboard/test/integration_tests/regions/menus.py create mode 100644 openstack_dashboard/test/integration_tests/regions/messages.py create mode 100644 openstack_dashboard/test/integration_tests/regions/tables.py create mode 100644 openstack_dashboard/test/integration_tests/tests/__init__.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test-data/empty_namespace.json create mode 100644 openstack_dashboard/test/integration_tests/tests/test_credentials.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_defaults.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_flavors.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_floatingips.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_groups.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_grouptypes.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_host_aggregates.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_images.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_instances.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_keypairs.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_login.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_metadata_definitions.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_networks.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_projects.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_router.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_router_gateway.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_security_groups.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_user_settings.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_users.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_volume_snapshots.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_volumes.py create mode 100644 openstack_dashboard/test/integration_tests/tests/test_volumetypes.py create mode 100644 openstack_dashboard/test/integration_tests/video_recorder.py delete mode 100644 releasenotes/notes/remove-legacy-integration-tests-82401b61d.yaml diff --git a/openstack_dashboard/templates/horizon/_scripts.html b/openstack_dashboard/templates/horizon/_scripts.html index 6d7ebb5dc2..d857fcd6ca 100644 --- a/openstack_dashboard/templates/horizon/_scripts.html +++ b/openstack_dashboard/templates/horizon/_scripts.html @@ -32,6 +32,9 @@ +{% if HORIZON_CONFIG.integration_tests_support %} + +{% endif %} diff --git a/openstack_dashboard/test/integration_tests/README.rst b/openstack_dashboard/test/integration_tests/README.rst new file mode 100644 index 0000000000..33968c7f12 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/README.rst @@ -0,0 +1,31 @@ +Horizon Integration Tests +========================= + +Horizon's integration tests treat Horizon as a black box. + +Running the integration tests +----------------------------- + +#. Set up an OpenStack server + +#. Prepare the configuration file at `local-horizon.conf` if you need + to change the default configurations. + Note that `horizon.conf` can be used for the same purpose too + from the historical reason. + + You can generate a sample configuration file by the following command:: + + $ oslo-config-generator \ + --namespace openstack_dashboard_integration_tests + --output-file openstack_dashboard/test/integration_tests/horizon.conf.sample + +#. Run the tests. :: + + $ tox -e integration + +More information +---------------- + +https://wiki.openstack.org/wiki/Horizon/Testing/UI + +https://wiki.mozilla.org/QA/Execution/Web_Testing/Docs/Automation/StyleGuide#Page_Objects diff --git a/openstack_dashboard/test/integration_tests/basewebobject.py b/openstack_dashboard/test/integration_tests/basewebobject.py new file mode 100644 index 0000000000..b6a6d60044 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/basewebobject.py @@ -0,0 +1,169 @@ +# 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 contextlib +import unittest + +import selenium.common.exceptions as Exceptions +from selenium.webdriver.common import by +from selenium.webdriver.remote import webelement +import selenium.webdriver.support.ui as Support +from selenium.webdriver.support import wait + + +class BaseWebObject(unittest.TestCase): + """Base class for all web objects.""" + _spinner_locator = (by.By.CSS_SELECTOR, '.modal-body > .loader') + + def __init__(self, driver, conf): + self.driver = driver + self.conf = conf + if conf is not None and conf.get('selenium', None) is not None: + self.explicit_wait = self.conf.selenium.explicit_wait + else: + self.explicit_wait = 0 + super().__init__() + + def _is_element_present(self, *locator): + with self.waits_disabled(): + try: + self._get_element(*locator) + return True + except Exceptions.NoSuchElementException: + return False + + def _is_element_visible(self, *locator): + try: + return self._get_element(*locator).is_displayed() + except (Exceptions.NoSuchElementException, + Exceptions.ElementNotVisibleException): + return False + + def _is_element_displayed(self, element): + if element is None: + return False + try: + if isinstance(element, webelement.WebElement): + return element.is_displayed() + else: + return element.src_elem.is_displayed() + except (Exceptions.ElementNotVisibleException, + Exceptions.StaleElementReferenceException): + return False + + def _is_text_visible(self, element, text, strict=True): + if not hasattr(element, 'text'): + return False + if strict: + return element.text == text + else: + return text in element.text + + def _get_element(self, *locator): + return self.driver.find_element(*locator) + + def _get_elements(self, *locator): + return self.driver.find_elements(*locator) + + def _fill_field_element(self, data, field_element): + field_element.clear() + field_element.send_keys(data) + return field_element + + def _select_dropdown(self, value, element): + select = Support.Select(element) + select.select_by_visible_text(value) + + def _select_dropdown_by_value(self, value, element): + select = Support.Select(element) + select.select_by_value(value) + + def _get_dropdown_options(self, element): + select = Support.Select(element) + return select.options + + def _turn_off_implicit_wait(self): + self.driver.implicitly_wait(0) + + def _turn_on_implicit_wait(self): + self.driver.implicitly_wait(self.conf.selenium.implicit_wait) + + def _wait_until(self, predicate, timeout=None, poll_frequency=0.5): + """Wait until the value returned by predicate is not False. + + It also returns when the timeout is elapsed. + 'predicate' takes the driver as argument. + """ + if not timeout: + timeout = self.explicit_wait + return wait.WebDriverWait(self.driver, timeout, poll_frequency).until( + predicate) + + def _wait_till_text_present_in_element(self, element, texts, timeout=None): + """Waiting for a text to appear in a certain element. + + Most frequent usage is actually to wait for a _different_ element + with a different text to appear in place of an old element. + So a way to avoid capturing stale element reference should be provided + for this use case. + + Better to wrap getting entity status cell in a lambda + to avoid problems with cell being replaced with totally different + element by Javascript + """ + if not isinstance(texts, (list, tuple)): + texts = (texts,) + + def predicate(_): + elt = element() if hasattr(element, '__call__') else element + for text in texts: + if self._is_text_visible(elt, text): + return text + return False + + return self._wait_until(predicate, timeout) + + def _wait_till_element_visible(self, locator, timeout=None): + self._wait_until(lambda x: self._is_element_visible(*locator), timeout) + + def _wait_till_element_disappears(self, element, timeout=None): + self._wait_until(lambda x: not self._is_element_displayed(element), + timeout) + + @contextlib.contextmanager + def waits_disabled(self): + try: + self._turn_off_implicit_wait() + yield + finally: + self._turn_on_implicit_wait() + + def wait_till_element_disappears(self, element_getter): + with self.waits_disabled(): + try: + self._wait_till_element_disappears(element_getter()) + except Exceptions.NoSuchElementException: + # NOTE(mpavlase): This is valid state. When request completes + # even before Selenium get a chance to get the spinner element, + # it will raise the NoSuchElementException exception. + pass + + def wait_until_element_is_visible(self, locator): + with self.waits_disabled(): + try: + self._wait_till_element_visible(locator) + except Exceptions.NoSuchElementException: + pass + + def wait_till_spinner_disappears(self): + def getter(): + return self.driver.find_element(*self._spinner_locator) + self.wait_till_element_disappears(getter) diff --git a/openstack_dashboard/test/integration_tests/decorators.py b/openstack_dashboard/test/integration_tests/decorators.py new file mode 100644 index 0000000000..20d008e552 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/decorators.py @@ -0,0 +1,176 @@ +# 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.abc +import functools +import inspect +import unittest + +from openstack_dashboard.test.integration_tests import config + + +def _is_test_method_name(method): + return method.startswith('test_') + + +def _is_test_fixture(method): + return method in ['setUp', 'tearDown'] + + +def _is_test_cls(cls): + return cls.__name__.startswith('Test') + + +def _mark_method_skipped(meth, reason): + """Decorate to mark method as skipped. + + This marks method as skipped by replacing the actual method with wrapper + that raises the unittest.SkipTest exception. + """ + + @functools.wraps(meth) + def wrapper(*args, **kwargs): + raise unittest.SkipTest(reason) + + return wrapper + + +def _mark_class_skipped(cls, reason): + """Mark every test method of the class as skipped.""" + tests = [attr for attr in dir(cls) if _is_test_method_name(attr) or + _is_test_fixture(attr)] + for test in tests: + method = getattr(cls, test) + if callable(method): + setattr(cls, test, _mark_method_skipped(method, reason)) + return cls + + +NOT_TEST_OBJECT_ERROR_MSG = "Decorator can be applied only on test" \ + " classes and test methods." + + +def _get_skip_method(obj): + """Make sure that we can decorate both methods and classes.""" + if inspect.isclass(obj): + if not _is_test_cls(obj): + raise ValueError(NOT_TEST_OBJECT_ERROR_MSG) + return _mark_class_skipped + else: + if not _is_test_method_name(obj.__name__): + raise ValueError(NOT_TEST_OBJECT_ERROR_MSG) + return _mark_method_skipped + + +def services_required(*req_services): + """Decorator for marking test's service requirements. + + If requirements are not met in the configuration file + test is marked as skipped. + + Usage: + from openstack_dashboard.test.integration_tests.tests import decorators + + @decorators.services_required("sahara") + class TestLogin(helpers.BaseTestCase): + . + . + . + + + from openstack_dashboard.test.integration_tests.tests import decorators + + class TestLogin(helpers.BaseTestCase): + + @decorators.services_required("sahara") + def test_login(self): + login_pg = loginpage.LoginPage(self.driver, self.conf) + . + . + . + """ + def actual_decoration(obj): + skip_method = _get_skip_method(obj) + # get available services from configuration + avail_services = config.get_config().service_available + for req_service in req_services: + if not getattr(avail_services, req_service, False): + obj = skip_method(obj, "%s service is required for this test" + " to work properly." % req_service) + break + return obj + return actual_decoration + + +def _parse_compound_config_option_value(option_name): + """Parses the value of a given config option. + + The section name of the option is separated from option name by '.'. + """ + name_parts = option_name.split('.') + name_parts.reverse() + option = config.get_config() + while name_parts: + option = getattr(option, name_parts.pop()) + return option + + +def config_option_required(option_key, required_value, message=None): + if message is None: + message = "%s option equal to '%s' is required for this test to work" \ + " properly." % (option_key, required_value) + + def actual_decoration(obj): + skip_method = _get_skip_method(obj) + option_value = _parse_compound_config_option_value(option_key) + if option_value != required_value: + obj = skip_method(obj, message) + return obj + return actual_decoration + + +def skip_because(**kwargs): + """Decorator for skipping tests hitting known bugs + + Usage: + from openstack_dashboard.test.integration_tests.tests import decorators + + class TestDashboardHelp(helpers.TestCase): + + @decorators.skip_because(bugs=["1234567"]) + def test_dashboard_help_redirection(self): + . + . + . + """ + def actual_decoration(obj): + skip_method = _get_skip_method(obj) + bugs = kwargs.get("bugs") + if bugs and isinstance(bugs, collections.abc.Iterable): + for bug in bugs: + if not bug.isdigit(): + raise ValueError("bug must be a valid bug number") + obj = skip_method(obj, "Skipped until Bugs: %s are resolved." % + ", ".join([bug for bug in bugs])) + return obj + return actual_decoration + + +def attach_video(func): + """Notify test runner to attach test video in any case""" + + @functools.wraps(func) + def wrapper(self, *args, **kwgs): + self._need_attach_video = True + return func(self, *args, **kwgs) + + return wrapper diff --git a/openstack_dashboard/test/integration_tests/helpers.py b/openstack_dashboard/test/integration_tests/helpers.py new file mode 100644 index 0000000000..32f9d43bdc --- /dev/null +++ b/openstack_dashboard/test/integration_tests/helpers.py @@ -0,0 +1,355 @@ +# 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 contextlib +import io +import logging +import os +import shutil +import socket +import subprocess +import tempfile +import time +import traceback +import unittest + +from django.test import tag +from oslo_utils import uuidutils +from selenium.webdriver.common import action_chains +from selenium.webdriver.common import by +from selenium.webdriver.common import keys +import testtools +import xvfbwrapper + +from horizon.test import helpers +from horizon.test import webdriver +from openstack_dashboard.test.integration_tests import config +from openstack_dashboard.test.integration_tests.pages import loginpage +from openstack_dashboard.test.integration_tests.regions import messages +from openstack_dashboard.test.integration_tests.video_recorder import \ + VideoRecorder + +# Set logging level to DEBUG for all logger here +# so that lower level messages are output even before starting tests. +ROOT_LOGGER = logging.getLogger() +ROOT_LOGGER.setLevel(logging.DEBUG) + +LOG = logging.getLogger(__name__) + +IS_SELENIUM_HEADLESS = os.environ.get('SELENIUM_HEADLESS', False) + +ROOT_PATH = os.path.dirname(os.path.abspath(config.__file__)) + +SCREEN_SIZE = (None, None) + +if not subprocess.call('which xdpyinfo > /dev/null 2>&1', shell=True): + try: + SCREEN_SIZE = subprocess.check_output( + 'xdpyinfo | grep dimensions', + shell=True).decode().split()[1].split('x') + except subprocess.CalledProcessError: + LOG.info("Can't run 'xdpyinfo'") +else: + LOG.info("X11 isn't installed. Should use xvfb to run tests.") + + +def gen_random_resource_name(resource="", timestamp=True): + """Generate random resource name using uuid and timestamp. + + Input fields are usually limited to 255 or 80 characters hence their + provide enough space for quite long resource names, but it might be + the case that maximum field length is quite restricted, it is then + necessary to consider using shorter resource argument or avoid using + timestamp by setting timestamp argument to False. + """ + fields = ["horizon"] + if resource: + fields.append(resource) + if timestamp: + tstamp = time.strftime("%d-%m-%H-%M-%S") + fields.append(tstamp) + fields.append(uuidutils.generate_uuid(dashed=False)) + return "_".join(fields) + + +@contextlib.contextmanager +def gen_temporary_file(name='', suffix='.qcow2', size=10485760): + """Generate temporary file with provided parameters. + + :param name: file name except the extension /suffix + :param suffix: file extension/suffix + :param size: size of the file to create, bytes are generated randomly + :return: path to the generated file + """ + with tempfile.NamedTemporaryFile(prefix=name, suffix=suffix) as tmp_file: + tmp_file.write(os.urandom(size)) + yield tmp_file.name + + +class AssertsMixin(object): + + def assertSequenceTrue(self, actual): + return self.assertEqual(list(actual), [True] * len(actual)) + + def assertSequenceFalse(self, actual): + return self.assertEqual(list(actual), [False] * len(actual)) + + +@helpers.pytest_mark('integration') +@tag('integration') +class BaseTestCase(testtools.TestCase): + + CONFIG = config.get_config() + + def setUp(self): + self._configure_log() + + self.addOnException( + lambda exc_info: setattr(self, '_need_attach_test_log', True)) + + def cleanup(): + if getattr(self, '_need_attach_test_log', None): + self._attach_test_log() + + self.addCleanup(cleanup) + + width, height = SCREEN_SIZE + display = '0.0' + # Start a virtual display server for running the tests headless. + if IS_SELENIUM_HEADLESS: + width, height = 1920, 1080 + self.vdisplay = xvfbwrapper.Xvfb(width=width, height=height) + args = [] + + # workaround for memory leak in Xvfb taken from: + # http://blog.jeffterrace.com/2012/07/xvfb-memory-leak-workaround.html + args.append("-noreset") + + # disables X access control + args.append("-ac") + + if hasattr(self.vdisplay, 'extra_xvfb_args'): + # xvfbwrapper 0.2.8 or newer + self.vdisplay.extra_xvfb_args.extend(args) + else: + self.vdisplay.xvfb_cmd.extend(args) + self.vdisplay.start() + display = self.vdisplay.new_display + + self.addCleanup(self.vdisplay.stop) + + self.video_recorder = VideoRecorder(width, height, display=display) + self.video_recorder.start() + + def attach_video(exc_info): + if getattr(self, '_attached_video', None): + return + self.video_recorder.stop() + self._attach_video() + setattr(self, '_attached_video', True) + + self.addOnException(attach_video) + + def cleanup(): + if getattr(self, '_attached_video', None): + return + self.video_recorder.stop() + self.video_recorder.clear() + + self.addCleanup(cleanup) + + # Increase the default Python socket timeout from nothing + # to something that will cope with slow webdriver startup times. + # This *just* affects the communication between this test process + # and the webdriver. + socket.setdefaulttimeout(60) + # Start the Selenium webdriver and setup configuration. + desired_capabilities = dict(webdriver.desired_capabilities) + desired_capabilities['loggingPrefs'] = {'browser': 'ALL'} + self.driver = webdriver.WebDriverWrapper( + desired_capabilities=desired_capabilities + ) + if self.CONFIG.selenium.maximize_browser: + self.driver.maximize_window() + if IS_SELENIUM_HEADLESS: # force full screen in xvfb + self.driver.set_window_size(width, height) + + self.driver.implicitly_wait(self.CONFIG.selenium.implicit_wait) + self.driver.set_page_load_timeout( + self.CONFIG.selenium.page_timeout) + + self.addOnException(self._attach_page_source) + self.addOnException(self._attach_screenshot) + self.addOnException( + lambda exc_info: setattr(self, '_need_attach_browser_log', True)) + + def cleanup(): + if getattr(self, '_need_attach_browser_log', None): + self._attach_browser_log() + self.driver.quit() + + self.addCleanup(cleanup) + + super().setUp() + + def addOnException(self, exception_handler): + + def wrapped_handler(exc_info): + if issubclass(exc_info[0], unittest.SkipTest): + return + return exception_handler(exc_info) + + super().addOnException(wrapped_handler) + + def __hash__(self): + return hash((type(self), self._testMethodName)) + + def _configure_log(self): + """Configure log to capture test logs include selenium logs. + + This allows us to attach them if test will be broken. + """ + # clear other handlers to set target handler + ROOT_LOGGER.handlers[:] = [] + self._log_buffer = io.StringIO() + stream_handler = logging.StreamHandler(stream=self._log_buffer) + stream_handler.setLevel(logging.DEBUG) + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s') + stream_handler.setFormatter(formatter) + ROOT_LOGGER.addHandler(stream_handler) + + @property + def _test_report_dir(self): + report_dir = os.path.join( + ROOT_PATH, self.CONFIG.selenium.screenshots_directory, + '{}.{}'.format(self.__class__.__name__, self._testMethodName)) + if not os.path.isdir(report_dir): + os.makedirs(report_dir) + return report_dir + + def _attach_page_source(self, exc_info): + source_path = os.path.join(self._test_report_dir, 'page.html') + with self.log_exception("Attach page source"): + with open(source_path, 'w') as f: + f.write(self._get_page_html_source()) + + def _attach_screenshot(self, exc_info): + screen_path = os.path.join(self._test_report_dir, 'screenshot.png') + with self.log_exception("Attach screenshot"): + self.driver.get_screenshot_as_file(screen_path) + + def _attach_video(self, exc_info=None): + with self.log_exception("Attach video"): + if not os.path.isfile(self.video_recorder.file_path): + LOG.warning("Can't find video %s", + self.video_recorder.file_path) + return + + shutil.move(self.video_recorder.file_path, + os.path.join(self._test_report_dir, 'video.mp4')) + + def _attach_browser_log(self, exc_info=None): + browser_log_path = os.path.join(self._test_report_dir, 'browser.log') + try: + log = self._unwrap_browser_log(self.driver.get_log('browser')) + except Exception: + log = traceback.format_exc() + with open(browser_log_path, 'w') as f: + f.write(log) + + def _attach_test_log(self, exc_info=None): + test_log_path = os.path.join(self._test_report_dir, 'test.log') + with self.log_exception("Attach test log"): + with open(test_log_path, 'w') as f: + f.write(self._log_buffer.getvalue()) + + @contextlib.contextmanager + def log_exception(self, label): + try: + yield + except Exception: + self.addDetail( + label, testtools.content.text_content(traceback.format_exc())) + + @staticmethod + def _unwrap_browser_log(_log): + def rec(log): + if isinstance(log, dict): + return log['message'].encode('utf-8') + elif isinstance(log, list): + return '\n'.join([rec(item) for item in log]) + else: + return log.encode('utf-8') + return rec(_log) + + def zoom_out(self, times=3): + """Zooming out a specified element. + + It prevents different elements being driven out of xvfb viewport + (which in Selenium>=2.50.1 prevents interaction with them). + """ + html = self.driver.find_element(by.By.TAG_NAME, 'html') + html.send_keys(keys.Keys.NULL) + zoom_out_keys = (keys.Keys.SUBTRACT,) * times + action_chains.ActionChains(self.driver).key_down( + keys.Keys.CONTROL).send_keys(*zoom_out_keys).key_up( + keys.Keys.CONTROL).perform() + + def _get_page_html_source(self): + """Gets html page source. + + self.driver.page_source is not used on purpose because it does not + display html code generated/changed by javascript. + """ + html_elem = self.driver.find_element_by_tag_name("html") + return html_elem.get_property("innerHTML") + + +@helpers.pytest_mark('integration') +@tag('integration') +class TestCase(BaseTestCase, AssertsMixin): + + TEST_USER_NAME = BaseTestCase.CONFIG.identity.username + TEST_PASSWORD = BaseTestCase.CONFIG.identity.password + HOME_PROJECT = BaseTestCase.CONFIG.identity.home_project + + def setUp(self): + super().setUp() + self.login_pg = loginpage.LoginPage(self.driver, self.CONFIG) + self.login_pg.go_to_login_page() + # TODO(schipiga): lets check that tests work without viewport changing, + # otherwise will uncomment. + # self.zoom_out() + self.home_pg = self.login_pg.login(self.TEST_USER_NAME, + self.TEST_PASSWORD) + self.home_pg.change_project(self.HOME_PROJECT) + self.assertEqual( + self.home_pg.find_messages_and_dismiss(), {messages.SUCCESS}) + + def cleanup(): + if self.home_pg.is_logged_in: + self.home_pg.go_to_home_page() + self.home_pg.log_out() + + self.addCleanup(cleanup) + + +class AdminTestCase(TestCase, AssertsMixin): + + TEST_USER_NAME = TestCase.CONFIG.identity.admin_username + TEST_PASSWORD = TestCase.CONFIG.identity.admin_password + HOME_PROJECT = BaseTestCase.CONFIG.identity.admin_home_project + + def setUp(self): + super().setUp() + self.home_pg.go_to_admin_overviewpage() diff --git a/openstack_dashboard/test/integration_tests/pages/__init__.py b/openstack_dashboard/test/integration_tests/pages/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/test/integration_tests/pages/admin/__init__.py b/openstack_dashboard/test/integration_tests/pages/admin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/test/integration_tests/pages/admin/compute/__init__.py b/openstack_dashboard/test/integration_tests/pages/admin/compute/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/test/integration_tests/pages/admin/compute/flavorspage.py b/openstack_dashboard/test/integration_tests/pages/admin/compute/flavorspage.py new file mode 100644 index 0000000000..22b6c7e196 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/admin/compute/flavorspage.py @@ -0,0 +1,155 @@ +# 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.regions import forms +from openstack_dashboard.test.integration_tests.regions import menus +from openstack_dashboard.test.integration_tests.regions import tables + + +class FlavorsTable(tables.TableRegion): + name = "flavors" + + CREATE_FLAVOR_FORM_FIELDS = (("name", "flavor_id", "vcpus", "memory_mb", + "disk_gb", "eph_gb", + "swap_mb"), + {"members": menus.MembershipMenuRegion}) + + UPDATE_FLAVOR_FORM_FIELDS = (("name", "vcpus", "memory_mb", + "disk_gb", "eph_gb", "swap_mb"), + {"members": menus.MembershipMenuRegion}) + + @tables.bind_table_action('create') + def create_flavor(self, create_button): + create_button.click() + return forms.TabbedFormRegion( + self.driver, + self.conf, + field_mappings=self.CREATE_FLAVOR_FORM_FIELDS + ) + + @tables.bind_row_action('update') + def update_flavor_info(self, edit_button, row): + edit_button.click() + return forms.TabbedFormRegion( + self.driver, + self.conf, + field_mappings=self.UPDATE_FLAVOR_FORM_FIELDS + ) + + @tables.bind_row_action('projects') + def update_flavor_access(self, update_button, row): + update_button.click() + return forms.TabbedFormRegion( + self.driver, + self.conf, + field_mappings=self.UPDATE_FLAVOR_FORM_FIELDS, + default_tab=1 + ) + + @tables.bind_row_action('delete') + def delete_by_row(self, delete_button, row): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + +class FlavorsPage(basepage.BaseNavigationPage): + DEFAULT_ID = "auto" + FLAVORS_TABLE_NAME_COLUMN = 'Flavor Name' + FLAVORS_TABLE_VCPUS_COLUMN = 'VCPUs' + FLAVORS_TABLE_RAM_COLUMN = 'RAM' + FLAVORS_TABLE_DISK_COLUMN = 'Root Disk' + FLAVORS_TABLE_PUBLIC_COLUMN = 'Public' + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "Flavors" + + @property + def flavors_table(self): + return FlavorsTable(self.driver, self.conf) + + def _get_flavor_row(self, name): + return self.flavors_table.get_row(self.FLAVORS_TABLE_NAME_COLUMN, name) + + def create_flavor(self, name, id_=DEFAULT_ID, vcpus=None, ram=None, + root_disk=None, ephemeral_disk=None, swap_disk=None): + create_flavor_form = self.flavors_table.create_flavor() + create_flavor_form.name.text = name + if id_ is not None: + create_flavor_form.flavor_id.text = id_ + create_flavor_form.vcpus.value = vcpus + create_flavor_form.memory_mb.value = ram + create_flavor_form.disk_gb.value = root_disk + create_flavor_form.eph_gb.value = ephemeral_disk + create_flavor_form.swap_mb.value = swap_disk + create_flavor_form.submit() + + def is_flavor_present(self, name): + return bool(self._get_flavor_row(name)) + + def update_flavor_info(self, name, add_up): + row = self._get_flavor_row(name) + update_flavor_form = self.flavors_table.update_flavor_info(row) + + update_flavor_form.name.text = "edited-" + name + update_flavor_form.vcpus.value = \ + int(update_flavor_form.vcpus.value) + add_up + update_flavor_form.memory_mb.value =\ + int(update_flavor_form.memory_mb.value) + add_up + update_flavor_form.disk_gb.value =\ + int(update_flavor_form.disk_gb.value) + add_up + + update_flavor_form.submit() + + def update_flavor_access(self, name, project_name, allocate=True): + row = self._get_flavor_row(name) + update_flavor_form = self.flavors_table.update_flavor_access(row) + + if allocate: + update_flavor_form.members.allocate_member(project_name) + else: + update_flavor_form.members.deallocate_member(project_name) + + update_flavor_form.submit() + + def delete_flavor_by_row(self, name): + row = self._get_flavor_row(name) + delete_form = self.flavors_table.delete_by_row(row) + delete_form.submit() + + def get_flavor_vcpus(self, name): + row = self._get_flavor_row(name) + return row.cells[self.FLAVORS_TABLE_VCPUS_COLUMN].text + + def get_flavor_ram(self, name): + row = self._get_flavor_row(name) + return row.cells[self.FLAVORS_TABLE_RAM_COLUMN].text + + def get_flavor_disk(self, name): + row = self._get_flavor_row(name) + return row.cells[self.FLAVORS_TABLE_DISK_COLUMN].text + + def is_flavor_public(self, name): + row = self._get_flavor_row(name) + return row.cells[self.FLAVORS_TABLE_PUBLIC_COLUMN].text == "Yes" + + +class FlavorsPageNG(FlavorsPage): + _resource_page_header_locator = (by.By.CSS_SELECTOR, + '.page-header > h1') + + @property + def header(self): + return self._get_element(*self._resource_page_header_locator) diff --git a/openstack_dashboard/test/integration_tests/pages/admin/compute/hostaggregatespage.py b/openstack_dashboard/test/integration_tests/pages/admin/compute/hostaggregatespage.py new file mode 100644 index 0000000000..1b3fd58fc7 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/admin/compute/hostaggregatespage.py @@ -0,0 +1,79 @@ +# 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 openstack_dashboard.test.integration_tests.pages import basepage +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables + + +class HostAggregatesTable(tables.TableRegion): + name = "host_aggregates" + + CREATE_HOST_AGGREGATE_FORM_FIELDS = (("name", + "availability_zone"),) + + @tables.bind_table_action('create') + def create_host_aggregate(self, create_button): + create_button.click() + return forms.TabbedFormRegion(self.driver, self.conf, + field_mappings=self. + CREATE_HOST_AGGREGATE_FORM_FIELDS) + + @tables.bind_table_action('delete') + def delete_host_aggregate(self, delete_button): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf, None) + + # Examples of how to bind to secondary actions + @tables.bind_row_action('update') + def update_host_aggregate(self, edit_host_aggregate_button, row): + edit_host_aggregate_button.click() + pass + + @tables.bind_row_action('manage') + def modify_access(self, manage_button, row): + manage_button.click() + pass + + +class HostaggregatesPage(basepage.BaseNavigationPage): + HOST_AGGREGATES_TABLE_NAME_COLUMN = 'Name' + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "Host Aggregates" + + @property + def host_aggregates_table(self): + return HostAggregatesTable(self.driver, self.conf) + + def _get_host_aggregate_row(self, name): + return self.host_aggregates_table.get_row( + self.HOST_AGGREGATES_TABLE_NAME_COLUMN, name) + + def create_host_aggregate(self, name, availability_zone): + create_host_aggregate_form = \ + self.host_aggregates_table.create_host_aggregate() + create_host_aggregate_form.name.text = name + create_host_aggregate_form.availability_zone.text = \ + availability_zone + create_host_aggregate_form.submit() + + def delete_host_aggregate(self, name): + row = self._get_host_aggregate_row(name) + row.mark() + modal_confirmation_form = self.host_aggregates_table.\ + delete_host_aggregate() + modal_confirmation_form.submit() + + def is_host_aggregate_present(self, name): + return bool(self._get_host_aggregate_row(name)) diff --git a/openstack_dashboard/test/integration_tests/pages/admin/compute/hypervisorspage.py b/openstack_dashboard/test/integration_tests/pages/admin/compute/hypervisorspage.py new file mode 100644 index 0000000000..bb18386984 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/admin/compute/hypervisorspage.py @@ -0,0 +1,20 @@ +# 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 openstack_dashboard.test.integration_tests.pages import basepage + + +class HypervisorsPage(basepage.BaseNavigationPage): + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "All Hypervisors" diff --git a/openstack_dashboard/test/integration_tests/pages/admin/compute/imagespage.py b/openstack_dashboard/test/integration_tests/pages/admin/compute/imagespage.py new file mode 100644 index 0000000000..adbf16908f --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/admin/compute/imagespage.py @@ -0,0 +1,18 @@ +# 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 openstack_dashboard.test.integration_tests.pages.project.compute \ + import imagespage + + +class ImagesPage(imagespage.ImagesPage): + pass diff --git a/openstack_dashboard/test/integration_tests/pages/admin/compute/instancespage.py b/openstack_dashboard/test/integration_tests/pages/admin/compute/instancespage.py new file mode 100644 index 0000000000..679662b9b4 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/admin/compute/instancespage.py @@ -0,0 +1,19 @@ +# 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 openstack_dashboard.test.integration_tests.pages.project.compute \ + import instancespage + + +class InstancesPage(instancespage.InstancesPage): + + INSTANCES_TABLE_NAME_COLUMN = 'Name' diff --git a/openstack_dashboard/test/integration_tests/pages/admin/network/__init__.py b/openstack_dashboard/test/integration_tests/pages/admin/network/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/test/integration_tests/pages/admin/network/floatingipspage.py b/openstack_dashboard/test/integration_tests/pages/admin/network/floatingipspage.py new file mode 100644 index 0000000000..97c4c9dcda --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/admin/network/floatingipspage.py @@ -0,0 +1,18 @@ +# 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 openstack_dashboard.test.integration_tests.pages.project.network \ + import floatingipspage + + +class FloatingipsPage(floatingipspage.FloatingipsPage): + pass diff --git a/openstack_dashboard/test/integration_tests/pages/admin/network/networkspage.py b/openstack_dashboard/test/integration_tests/pages/admin/network/networkspage.py new file mode 100644 index 0000000000..f3a20dfc88 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/admin/network/networkspage.py @@ -0,0 +1,38 @@ +# 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 openstack_dashboard.test.integration_tests.pages.project.network \ + import networkspage + + +class NetworksPage(networkspage.NetworksPage): + + NETWORKS_TABLE_NAME_COLUMN = 'Network Name' + + @property + def is_admin(self): + return True + + @property + def networks_table(self): + return NetworksTable(self.driver, self.conf) + + +class NetworksTable(networkspage.NetworksTable): + + CREATE_NETWORK_FORM_FIELDS = (("name", "admin_state", + "with_subnet", "az_hints", "tenant_id", + "network_type"), + ("subnet_name", "cidr", "ip_version", + "gateway_ip", "no_gateway"), + ("enable_dhcp", "allocation_pools", + "dns_nameservers", "host_routes")) diff --git a/openstack_dashboard/test/integration_tests/pages/admin/network/routerspage.py b/openstack_dashboard/test/integration_tests/pages/admin/network/routerspage.py new file mode 100644 index 0000000000..6ec30ba464 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/admin/network/routerspage.py @@ -0,0 +1,41 @@ +# 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 openstack_dashboard.test.integration_tests.pages.project.network \ + import routerspage +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables + + +class RoutersTable(routerspage.RoutersTable): + EDIT_ROUTER_FORM_FIELDS = ("name", "admin_state") + + @tables.bind_row_action('update') + def edit_router(self, edit_button, row): + edit_button.click() + return forms.FormRegion(self.driver, self.conf, None, + self.EDIT_ROUTER_FORM_FIELDS) + + +class RoutersPage(routerspage.RoutersPage): + + @property + def routers_table(self): + return RoutersTable(self.driver, self.conf) + + def edit_router(self, name, new_name, admin_state=None): + row = self._get_row_with_router_name(name) + edit_router_form = self.routers_table.edit_router(row) + edit_router_form.name.text = new_name + if admin_state is not None: + edit_router_form.admin_state.text = admin_state + edit_router_form.submit() diff --git a/openstack_dashboard/test/integration_tests/pages/admin/overviewpage.py b/openstack_dashboard/test/integration_tests/pages/admin/overviewpage.py new file mode 100644 index 0000000000..ddfe8d58c8 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/admin/overviewpage.py @@ -0,0 +1,19 @@ +# 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 openstack_dashboard.test.integration_tests.pages import basepage + + +class OverviewPage(basepage.BaseNavigationPage): + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "Overview" diff --git a/openstack_dashboard/test/integration_tests/pages/admin/system/__init__.py b/openstack_dashboard/test/integration_tests/pages/admin/system/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/test/integration_tests/pages/admin/system/defaultspage.py b/openstack_dashboard/test/integration_tests/pages/admin/system/defaultspage.py new file mode 100644 index 0000000000..11624d219a --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/admin/system/defaultspage.py @@ -0,0 +1,166 @@ +# 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.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables + + +class DefaultQuotasTable(tables.TableRegion): + name = "compute_quotas" + + UPDATE_DEFAULTS_FORM_FIELDS = (("injected_file_content_bytes", + "metadata_items", "ram", + "key_pairs", + "injected_file_path_bytes", + "instances", "injected_files", + "cores", "server_groups", + "server_group_members"), + ("volumes", "gigabytes", + "snapshots")) + + @tables.bind_table_action('update_compute_defaults') + def update(self, update_button): + update_button.click() + return forms.TabbedFormRegion( + self.driver, + self.conf, + self.UPDATE_DEFAULTS_FORM_FIELDS) + + +class DefaultVolumeQuotasTable(DefaultQuotasTable): + name = "volume_quotas" + + @tables.bind_table_action('update_volume_defaults') + def update(self, update_button): + update_button.click() + return forms.TabbedFormRegion( + self.driver, + self.conf, + self.UPDATE_DEFAULTS_FORM_FIELDS) + + +class DefaultsPage(basepage.BaseNavigationPage): + + QUOTAS_TABLE_NAME_COLUMN = 'Quota Name' + QUOTAS_TABLE_LIMIT_COLUMN = 'Limit' + VOLUMES_TAB_INDEX = 1 + DEFAULT_COMPUTE_QUOTA_NAMES = [ + 'Injected File Content (B)', + 'Metadata Items', + 'Server Group Members', + 'Server Groups', + 'RAM (MB)', + 'Key Pairs', + 'Length of Injected File Path', + 'Instances', + 'Injected Files', + 'VCPUs' + ] + DEFAULT_VOLUME_QUOTA_NAMES = [ + 'Volumes', + 'Total Size of Volumes and Snapshots (GiB)', + 'Volume Snapshots', + ] + _volume_quotas_tab_locator = (by.By.CSS_SELECTOR, + 'a[href*="defaults__volume_quotas"]') + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "Defaults" + + def _get_compute_quota_row(self, name): + return self.default_compute_quotas_table.get_row( + self.QUOTAS_TABLE_NAME_COLUMN, name) + + def _get_volume_quota_row(self, name): + return self.default_volume_quotas_table.get_row( + self.QUOTAS_TABLE_NAME_COLUMN, name) + + @property + def default_compute_quotas_table(self): + return DefaultQuotasTable(self.driver, self.conf) + + @property + def default_volume_quotas_table(self): + return DefaultVolumeQuotasTable(self.driver, self.conf) + + @property + def compute_quota_values(self): + quota_dict = {} + for row in self.default_compute_quotas_table.rows: + if row.cells[self.QUOTAS_TABLE_NAME_COLUMN].text in \ + self.DEFAULT_COMPUTE_QUOTA_NAMES: + quota_dict[row.cells[self.QUOTAS_TABLE_NAME_COLUMN].text] =\ + int(row.cells[self.QUOTAS_TABLE_LIMIT_COLUMN].text) + return quota_dict + + @property + def volume_quota_values(self): + quota_dict = {} + for row in self.default_volume_quotas_table.rows: + if row.cells[self.QUOTAS_TABLE_NAME_COLUMN].text in \ + self.DEFAULT_VOLUME_QUOTA_NAMES: + quota_dict[row.cells[self.QUOTAS_TABLE_NAME_COLUMN].text] =\ + int(row.cells[self.QUOTAS_TABLE_LIMIT_COLUMN].text) + return quota_dict + + @property + def volume_quotas_tab(self): + return self._get_element(*self._volume_quotas_tab_locator) + + def update_compute_defaults(self, add_up): + update_form = self.default_compute_quotas_table.update() + update_form.injected_file_content_bytes.value = \ + int(update_form.injected_file_content_bytes.value) + add_up + + update_form.metadata_items.value = \ + int(update_form.metadata_items.value) + add_up + + update_form.server_group_members.value = \ + int(update_form.server_group_members.value) + add_up + + update_form.server_groups.value = \ + int(update_form.server_groups.value) + add_up + + update_form.ram.value = int(update_form.ram.value) + add_up + update_form.key_pairs.value = int(update_form.key_pairs.value) + add_up + update_form.injected_file_path_bytes.value = \ + int(update_form.injected_file_path_bytes.value) + add_up + + update_form.instances.value = int(update_form.instances.value) + add_up + update_form.injected_files.value = int( + update_form.injected_files.value) + add_up + update_form.cores.value = int(update_form.cores.value) + add_up + + update_form.submit() + + def update_volume_defaults(self, add_up): + update_form = self.default_volume_quotas_table.update() + update_form.switch_to(self.VOLUMES_TAB_INDEX) + update_form.volumes.value = int(update_form.volumes.value) + add_up + update_form.gigabytes.value = int(update_form.gigabytes.value) + add_up + update_form.snapshots.value = int(update_form.snapshots.value) + add_up + update_form.submit() + + def is_compute_quota_a_match(self, quota_name, limit): + row = self._get_compute_quota_row(quota_name) + return row.cells[self.QUOTAS_TABLE_LIMIT_COLUMN].text == str(limit) + + def is_volume_quota_a_match(self, quota_name, limit): + row = self._get_volume_quota_row(quota_name) + return row.cells[self.QUOTAS_TABLE_LIMIT_COLUMN].text == str(limit) + + def go_to_volume_quotas_tab(self): + self.volume_quotas_tab.click() diff --git a/openstack_dashboard/test/integration_tests/pages/admin/system/imagespage.py b/openstack_dashboard/test/integration_tests/pages/admin/system/imagespage.py new file mode 100644 index 0000000000..4af2403e04 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/admin/system/imagespage.py @@ -0,0 +1,17 @@ +# 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 openstack_dashboard.test.integration_tests.pages.project.compute \ + import imagespage + + +class ImagesPage(imagespage.ImagesPage): + pass diff --git a/openstack_dashboard/test/integration_tests/pages/admin/system/metadatadefinitionspage.py b/openstack_dashboard/test/integration_tests/pages/admin/system/metadatadefinitionspage.py new file mode 100644 index 0000000000..522901018b --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/admin/system/metadatadefinitionspage.py @@ -0,0 +1,128 @@ +# 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 json + +from openstack_dashboard.test.integration_tests.pages import basepage +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables + + +class MetadatadefinitionsTable(tables.TableRegion): + name = "namespaces" + CREATE_NAMESPACE_FORM_FIELDS = ( + "source_type", "direct_input", "metadef_file", "public", "protected") + + @tables.bind_table_action('import') + def import_namespace(self, create_button): + create_button.click() + return forms.FormRegion( + self.driver, + self.conf, + field_mappings=self.CREATE_NAMESPACE_FORM_FIELDS) + + @tables.bind_table_action('delete') + def delete_namespace(self, delete_button): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + +class MetadatadefinitionsPage(basepage.BaseNavigationPage): + + NAMESPACE_TABLE_NAME_COLUMN = 'Name' + NAMESPACE_TABLE_DESCRIPTION_COLUMN = 'Description' + NAMESPACE_TABLE_RESOURCE_TYPES_COLUMN = 'Resource Types' + NAMESPACE_TABLE_PUBLIC_COLUMN = 'Public' + NAMESPACE_TABLE_PROTECTED_COLUMN = 'Protected' + + boolean_mapping = {True: 'Yes', False: 'No'} + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "Metadata Definitions" + + def _get_row_with_namespace_name(self, name): + return self.namespaces_table.get_row( + self.NAMESPACE_TABLE_NAME_COLUMN, + name) + + @property + def namespaces_table(self): + return MetadatadefinitionsTable(self.driver, self.conf) + + def json_load_template(self, namespace_template_name): + """Read template for namespace creation + + :param namespace_template_name: Path to template + :return = json data container + """ + try: + with open(namespace_template_name, 'r') as template: + json_template = json.load(template) + except Exception: + raise EOFError("Can not read template file: [{0}]".format( + namespace_template_name)) + return json_template + + def import_namespace( + self, namespace_source_type, namespace_json_container, + is_public=True, is_protected=False): + + create_namespace_form = self.namespaces_table.import_namespace() + create_namespace_form.source_type.value = namespace_source_type + if namespace_source_type == 'raw': + json_template_dump = json.dumps(namespace_json_container) + create_namespace_form.direct_input.text = json_template_dump + elif namespace_source_type == 'file': + metadeffile = namespace_json_container + create_namespace_form.metadef_file.choose(metadeffile) + + if is_public: + create_namespace_form.public.mark() + if is_protected: + create_namespace_form.protected.mark() + + create_namespace_form.submit() + + def delete_namespace(self, name): + row = self._get_row_with_namespace_name(name) + row.mark() + confirm_delete_namespaces_form = \ + self.namespaces_table.delete_namespace() + confirm_delete_namespaces_form.submit() + + def is_namespace_present(self, name): + return bool(self._get_row_with_namespace_name(name)) + + def is_public_set_correct(self, name, exp_value, row=None): + if not isinstance(exp_value, bool): + raise ValueError('Expected value "exp_value" is not boolean') + if not row: + row = self._get_row_with_namespace_name(name) + cell = row.cells[self.NAMESPACE_TABLE_PUBLIC_COLUMN] + return self._is_text_visible(cell, self.boolean_mapping[exp_value]) + + def is_protected_set_correct(self, name, exp_value, row=None): + if not isinstance(exp_value, bool): + raise ValueError('Expected value "exp_value" is not boolean') + if not row: + row = self._get_row_with_namespace_name(name) + cell = row.cells[self.NAMESPACE_TABLE_PROTECTED_COLUMN] + return self._is_text_visible(cell, self.boolean_mapping[exp_value]) + + def is_resource_type_set_correct(self, name, expected_resources, row=None): + if not row: + row = self._get_row_with_namespace_name(name) + cell = row.cells[self.NAMESPACE_TABLE_RESOURCE_TYPES_COLUMN] + return all( + [self._is_text_visible(cell, res, strict=False) + for res in expected_resources]) diff --git a/openstack_dashboard/test/integration_tests/pages/admin/system/resource_usage/__init__.py b/openstack_dashboard/test/integration_tests/pages/admin/system/resource_usage/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/test/integration_tests/pages/admin/system/system_info/__init__.py b/openstack_dashboard/test/integration_tests/pages/admin/system/system_info/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/test/integration_tests/pages/admin/volume/__init__.py b/openstack_dashboard/test/integration_tests/pages/admin/volume/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/test/integration_tests/pages/admin/volume/grouptypespage.py b/openstack_dashboard/test/integration_tests/pages/admin/volume/grouptypespage.py new file mode 100644 index 0000000000..73e81b8477 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/admin/volume/grouptypespage.py @@ -0,0 +1,70 @@ +# 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 openstack_dashboard.test.integration_tests.pages import basepage +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables + + +class GroupTypesTable(tables.TableRegion): + name = 'group_types' + + CREATE_GROUP_TYPE_FORM_FIELDS = ("name", "group_type_description") + + @tables.bind_table_action('create') + def create_group_type(self, create_button): + create_button.click() + return forms.FormRegion( + self.driver, self.conf, + field_mappings=self.CREATE_GROUP_TYPE_FORM_FIELDS) + + @tables.bind_table_action('delete') + def delete_group_type(self, delete_button): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + +class GrouptypesPage(basepage.BaseNavigationPage): + GROUP_TYPES_TABLE_NAME_COLUMN = 'Name' + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "Group Types" + + @property + def group_types_table(self): + return GroupTypesTable(self.driver, self.conf) + + def _get_row_with_group_type_name(self, name): + return self.group_types_table.get_row( + self.GROUP_TYPES_TABLE_NAME_COLUMN, name) + + def create_group_type(self, group_type_name, description=None): + group_type_form = self.group_types_table.create_group_type() + group_type_form.name.text = group_type_name + if description is not None: + group_type_form.description.text = description + group_type_form.submit() + + def delete_group_type(self, name): + row = self._get_row_with_group_type_name(name) + row.mark() + confirm_delete_group_types_form = \ + self.group_types_table.delete_group_type() + confirm_delete_group_types_form.submit() + + def is_group_type_present(self, name): + return bool(self._get_row_with_group_type_name(name)) + + def is_group_type_deleted(self, name): + return self.group_types_table.is_row_deleted( + lambda: self._get_row_with_group_type_name(name)) diff --git a/openstack_dashboard/test/integration_tests/pages/admin/volume/snapshotspage.py b/openstack_dashboard/test/integration_tests/pages/admin/volume/snapshotspage.py new file mode 100644 index 0000000000..8be6ac5e5a --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/admin/volume/snapshotspage.py @@ -0,0 +1,18 @@ +# 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 openstack_dashboard.test.integration_tests.pages.project.\ + volumes import snapshotspage + + +class SnapshotsPage(snapshotspage.SnapshotsPage): + pass diff --git a/openstack_dashboard/test/integration_tests/pages/admin/volume/volumespage.py b/openstack_dashboard/test/integration_tests/pages/admin/volume/volumespage.py new file mode 100644 index 0000000000..af25856672 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/admin/volume/volumespage.py @@ -0,0 +1,18 @@ +# 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 openstack_dashboard.test.integration_tests.pages.project.\ + volumes import volumespage + + +class VolumesPage(volumespage.VolumesPage): + pass diff --git a/openstack_dashboard/test/integration_tests/pages/admin/volume/volumetypespage.py b/openstack_dashboard/test/integration_tests/pages/admin/volume/volumetypespage.py new file mode 100644 index 0000000000..605b9329cb --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/admin/volume/volumetypespage.py @@ -0,0 +1,135 @@ +# 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 openstack_dashboard.test.integration_tests.pages import basepage +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables + + +class QosSpecsTable(tables.TableRegion): + name = 'qos_specs' + CREATE_QOS_SPEC_FORM_FIELDS = ("name", "consumer") + EDIT_CONSUMER_FORM_FIELDS = ("consumer_choice", ) + + @tables.bind_table_action('create') + def create_qos_spec(self, create_button): + create_button.click() + return forms.FormRegion( + self.driver, self.conf, + field_mappings=self.CREATE_QOS_SPEC_FORM_FIELDS) + + @tables.bind_table_action('delete') + def delete_qos_specs(self, delete_button): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + @tables.bind_row_action('edit_consumer') + def edit_consumer(self, edit_consumer_button, row): + edit_consumer_button.click() + return forms.FormRegion( + self.driver, self.conf, + field_mappings=self.EDIT_CONSUMER_FORM_FIELDS) + + +class VolumeTypesTable(tables.TableRegion): + name = 'volume_types' + + CREATE_VOLUME_TYPE_FORM_FIELDS = ( + "name", "vol_type_description") + + @tables.bind_table_action('create') + def create_volume_type(self, create_button): + create_button.click() + return forms.FormRegion( + self.driver, self.conf, + field_mappings=self.CREATE_VOLUME_TYPE_FORM_FIELDS) + + @tables.bind_table_action('delete') + def delete_volume_type(self, delete_button): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + +class VolumetypesPage(basepage.BaseNavigationPage): + QOS_SPECS_TABLE_NAME_COLUMN = 'Name' + VOLUME_TYPES_TABLE_NAME_COLUMN = 'Name' + QOS_SPECS_TABLE_CONSUMER_COLUMN = 'Consumer' + CINDER_CONSUMER = 'back-end' + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "Volume Types" + + @property + def qos_specs_table(self): + return QosSpecsTable(self.driver, self.conf) + + @property + def volume_types_table(self): + return VolumeTypesTable(self.driver, self.conf) + + def _get_row_with_qos_spec_name(self, name): + return self.qos_specs_table.get_row( + self.QOS_SPECS_TABLE_NAME_COLUMN, name) + + def _get_row_with_volume_type_name(self, name): + return self.volume_types_table.get_row( + self.VOLUME_TYPES_TABLE_NAME_COLUMN, name) + + def create_qos_spec(self, qos_spec_name, consumer=CINDER_CONSUMER): + create_qos_spec_form = self.qos_specs_table.create_qos_spec() + create_qos_spec_form.name.text = qos_spec_name + create_qos_spec_form.submit() + + def create_volume_type(self, volume_type_name, description=None): + volume_type_form = self.volume_types_table.create_volume_type() + volume_type_form.name.text = volume_type_name + if description is not None: + volume_type_form.description.text = description + volume_type_form.submit() + + def delete_qos_specs(self, name): + row = self._get_row_with_qos_spec_name(name) + row.mark() + confirm_delete_qos_spec_form = self.qos_specs_table.delete_qos_specs() + confirm_delete_qos_spec_form.submit() + + def delete_volume_type(self, name): + row = self._get_row_with_volume_type_name(name) + row.mark() + confirm_delete_volume_types_form = \ + self.volume_types_table.delete_volume_type() + confirm_delete_volume_types_form.submit() + + def edit_consumer(self, name, consumer_choice): + row = self._get_row_with_qos_spec_name(name) + edit_consumer_form = self.qos_specs_table.edit_consumer(row) + edit_consumer_form.consumer_choice.value = consumer_choice + edit_consumer_form.submit() + + def is_qos_spec_present(self, name): + return bool(self._get_row_with_qos_spec_name(name)) + + def is_volume_type_present(self, name): + return bool(self._get_row_with_volume_type_name(name)) + + def is_qos_spec_deleted(self, name): + return self.qos_specs_table.is_row_deleted( + lambda: self._get_row_with_qos_spec_name(name)) + + def is_volume_type_deleted(self, name): + return self.volume_types_table.is_row_deleted( + lambda: self._get_row_with_volume_type_name(name)) + + def get_consumer(self, name): + row = self._get_row_with_qos_spec_name(name) + return row.cells[self.QOS_SPECS_TABLE_CONSUMER_COLUMN].text diff --git a/openstack_dashboard/test/integration_tests/pages/basepage.py b/openstack_dashboard/test/integration_tests/pages/basepage.py new file mode 100644 index 0000000000..62827f5986 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/basepage.py @@ -0,0 +1,89 @@ +# 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 selenium.common.exceptions as Exceptions +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 +from openstack_dashboard.test.integration_tests.regions import messages + + +class BasePage(pageobject.PageObject): + """Base class for all dashboard page objects.""" + + _heading_locator = (by.By.CSS_SELECTOR, 'div.page-header > h2') + _help_page_brand = (by.By.CSS_SELECTOR, '.navbar-brand') + _default_message_locator = (by.By.CSS_SELECTOR, 'div.alert') + + @property + def heading(self): + return self._get_element(*self._heading_locator) + + @property + def topbar(self): + return bars.TopBarRegion(self.driver, self.conf) + + @property + def is_logged_in(self): + return self.topbar.is_logged_in + + @property + def navaccordion(self): + return menus.NavigationAccordionRegion(self.driver, self.conf) + + def go_to_login_page(self): + self.driver.get(self.login_url) + + def go_to_home_page(self): + self.topbar.brand.click() + + def log_out(self): + self.topbar.user_dropdown_menu.click_on_logout() + + def go_to_help_page(self): + self.topbar.user_dropdown_menu.click_on_help() + + def is_help_page(self): + self._wait_till_element_visible(self._help_page_brand) + + def choose_theme(self, theme_name): + self.topbar.user_dropdown_menu.choose_theme(theme_name) + + def find_all_messages(self): + self.driver.implicitly_wait(self.conf.selenium.message_implicit_wait) + try: + msg_elements = self.driver.find_elements( + *self._default_message_locator) + except Exceptions.NoSuchElementException: + msg_elements = [] + finally: + self._turn_on_implicit_wait() + return msg_elements + + def find_messages_and_dismiss(self): + messages_level_present = set() + for message_element in self.find_all_messages(): + message = messages.MessageRegion( + self.driver, self.conf, message_element) + messages_level_present.add(message.message_class) + message.close() + return messages_level_present + + def change_project(self, name): + self.topbar.user_dropdown_project.click_on_project(name) + + +class BaseNavigationPage(BasePage, navigation.Navigation): + pass diff --git a/openstack_dashboard/test/integration_tests/pages/identity/__init__.py b/openstack_dashboard/test/integration_tests/pages/identity/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/test/integration_tests/pages/identity/groupspage.py b/openstack_dashboard/test/integration_tests/pages/identity/groupspage.py new file mode 100644 index 0000000000..d681c393b8 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/identity/groupspage.py @@ -0,0 +1,84 @@ +# 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 openstack_dashboard.test.integration_tests.pages import basepage +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables + + +class GroupsTable(tables.TableRegion): + + name = "groups" + + @property + def form_fields(self): + return ("name", "description") + + @tables.bind_table_action('create') + def create_group(self, create_button): + create_button.click() + return forms.FormRegion(self.driver, self.conf, + field_mappings=self.form_fields) + + @tables.bind_table_action('delete') + def delete_group(self, delete_button): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + @tables.bind_row_action('edit') + def edit_group(self, edit_button, row): + edit_button.click() + return forms.FormRegion(self.driver, self.conf, + field_mappings=self.form_fields) + + +class GroupsPage(basepage.BaseNavigationPage): + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = 'Groups' + + @property + def table_name_column(self): + return "Name" + + @property + def groups_table(self): + return GroupsTable(self.driver, self.conf) + + def _get_row_with_group_name(self, name): + return self.groups_table.get_row(self.table_name_column, name) + + def create_group(self, name, description=None): + create_form = self.groups_table.create_group() + create_form.name.text = name + if description is not None: + create_form.description.text = description + create_form.submit() + + def delete_group(self, name): + row = self._get_row_with_group_name(name) + row.mark() + confirm_delete_form = self.groups_table.delete_group() + confirm_delete_form.submit() + + def edit_group(self, name, new_name=None, new_description=None): + row = self._get_row_with_group_name(name) + edit_form = self.groups_table.edit_group(row) + if new_name is not None: + edit_form.name.text = new_name + if new_description is not None: + edit_form.description.text = new_description + edit_form.submit() + + def is_group_present(self, name): + return bool(self._get_row_with_group_name(name)) diff --git a/openstack_dashboard/test/integration_tests/pages/identity/projectspage.py b/openstack_dashboard/test/integration_tests/pages/identity/projectspage.py new file mode 100644 index 0000000000..7037978eb0 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/identity/projectspage.py @@ -0,0 +1,100 @@ +# 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 openstack_dashboard.test.integration_tests.pages import basepage +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import menus +from openstack_dashboard.test.integration_tests.regions import tables + + +class ProjectForm(forms.TabbedFormRegion): + FIELDS = (("name", "description", "enabled"), + {'members': menus.MembershipMenuRegion}) + + def __init__(self, driver, conf, tab=0): + super().__init__(driver, conf, field_mappings=self.FIELDS, + default_tab=tab) + + +class ProjectsTable(tables.TableRegion): + name = 'tenants' + + @tables.bind_table_action('create') + def create_project(self, create_button): + create_button.click() + return ProjectForm(self.driver, self.conf) + + @tables.bind_row_action('update') + def update_members(self, members_button, row): + members_button.click() + return ProjectForm(self.driver, self.conf, tab=1) + + @tables.bind_table_action('delete') + def delete_project(self, delete_button): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf, None) + + +class ProjectsPage(basepage.BaseNavigationPage): + + DEFAULT_ENABLED = True + PROJECTS_TABLE_NAME_COLUMN = 'Name' + PROJECT_ID_TABLE_NAME_COLUMN = 'Project ID' + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "Projects" + + @property + def projects_table(self): + return ProjectsTable(self.driver, self.conf) + + def _get_row_with_project_name(self, name): + return self.projects_table.get_row(self.PROJECTS_TABLE_NAME_COLUMN, + name) + + def create_project(self, project_name, description=None, + is_enabled=DEFAULT_ENABLED): + create_project_form = self.projects_table.create_project() + create_project_form.name.text = project_name + if description is not None: + create_project_form.description.text = description + if not is_enabled: + create_project_form.enabled.unmark() + create_project_form.submit() + + def delete_project(self, project_name): + row = self._get_row_with_project_name(project_name) + row.mark() + modal_confirmation_form = self.projects_table.delete_project() + modal_confirmation_form.submit() + + def is_project_present(self, project_name): + return bool(self._get_row_with_project_name(project_name)) + + def get_project_id_from_row(self, name): + row = self._get_row_with_project_name(name) + return row.cells[self.PROJECT_ID_TABLE_NAME_COLUMN].text + + def allocate_user_to_project(self, user_name, roles, project_name): + row = self._get_row_with_project_name(project_name) + members_form = self.projects_table.update_members(row) + members_form.members.allocate_member(user_name) + members_form.members.allocate_member_roles(user_name, roles) + members_form.submit() + + def get_user_roles_at_project(self, user_name, project_name): + row = self._get_row_with_project_name(project_name) + members_form = self.projects_table.update_members(row) + roles = members_form.members.get_member_allocated_roles(user_name) + members_form.cancel() + return set(roles) diff --git a/openstack_dashboard/test/integration_tests/pages/identity/rolespage.py b/openstack_dashboard/test/integration_tests/pages/identity/rolespage.py new file mode 100644 index 0000000000..21a5df96d0 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/identity/rolespage.py @@ -0,0 +1,20 @@ +# 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 openstack_dashboard.test.integration_tests.pages import basepage + + +class RolesPage(basepage.BaseNavigationPage): + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = 'Roles' diff --git a/openstack_dashboard/test/integration_tests/pages/identity/userspage.py b/openstack_dashboard/test/integration_tests/pages/identity/userspage.py new file mode 100644 index 0000000000..ebbe28922a --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/identity/userspage.py @@ -0,0 +1,69 @@ +# 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 openstack_dashboard.test.integration_tests.pages import basepage +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables + + +class UsersTable(tables.TableRegion): + name = 'users' + CREATE_USER_FORM_FIELDS = ("name", "email", "password", + "confirm_password", "project", "role_id") + + @tables.bind_table_action('create') + def create_user(self, create_button): + create_button.click() + return forms.FormRegion(self.driver, self.conf, + field_mappings=self.CREATE_USER_FORM_FIELDS) + + @tables.bind_table_action('delete') + def delete_user(self, delete_button): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + +class UsersPage(basepage.BaseNavigationPage): + + USERS_TABLE_NAME_COLUMN = 'User Name' + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "Users" + + def _get_row_with_user_name(self, name): + return self.users_table.get_row(self.USERS_TABLE_NAME_COLUMN, name) + + @property + def users_table(self): + return UsersTable(self.driver, self.conf) + + def create_user(self, name, password, + project, role, email=None): + create_user_form = self.users_table.create_user() + create_user_form.name.text = name + if email is not None: + create_user_form.email.text = email + create_user_form.password.text = password + create_user_form.confirm_password.text = password + create_user_form.project.text = project + create_user_form.role_id.text = role + create_user_form.submit() + + def delete_user(self, name): + row = self._get_row_with_user_name(name) + row.mark() + confirm_delete_users_form = self.users_table.delete_user() + confirm_delete_users_form.submit() + + def is_user_present(self, name): + return bool(self._get_row_with_user_name(name)) diff --git a/openstack_dashboard/test/integration_tests/pages/loginpage.py b/openstack_dashboard/test/integration_tests/pages/loginpage.py new file mode 100644 index 0000000000..35bba3da87 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/loginpage.py @@ -0,0 +1,99 @@ +# 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 selenium.webdriver.common import keys + +from openstack_dashboard.test.integration_tests.pages.admin import \ + overviewpage as system_overviewpage +from openstack_dashboard.test.integration_tests.pages import pageobject +from openstack_dashboard.test.integration_tests.pages.project.compute import \ + overviewpage as compute_overviewpage + + +class LoginPage(pageobject.PageObject): + _login_domain_field_locator = (by.By.ID, 'id_domain') + _login_username_field_locator = (by.By.ID, 'id_username') + _login_password_field_locator = (by.By.ID, 'id_password') + _login_submit_button_locator = (by.By.CSS_SELECTOR, + 'div.panel-footer button.btn') + _login_logout_reason_locator = (by.By.ID, 'logout_reason') + + def __init__(self, driver, conf): + super().__init__(driver, conf) + 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)) + + @property + def domain(self): + return self._get_elements(*self._login_domain_field_locator) + + @property + def username(self): + return self._get_element(*self._login_username_field_locator) + + @property + def password(self): + return self._get_element(*self._login_password_field_locator) + + @property + def login_button(self): + return self._get_element(*self._login_submit_button_locator) + + def _click_on_login_button(self): + self.login_button.click() + + def _press_enter_on_login_button(self): + self.login_button.send_keys(keys.Keys.RETURN) + + def is_logout_reason_displayed(self): + return self._get_element(*self._login_logout_reason_locator) + + def login(self, user=None, password=None): + return self.login_with_mouse_click(user, password) + + def login_with_mouse_click(self, user, password): + return self._do_login(user, password, self._click_on_login_button) + + def login_with_enter_key(self, user, password): + return self._do_login(user, password, + self._press_enter_on_login_button) + + def _do_login(self, user, password, login_method): + if self.conf.identity.domain: + self.domain[0].clear() + self.domain[0].send_keys(self.conf.identity.domain) + if user == self.conf.identity.admin_username: + if password is None: + password = self.conf.identity.admin_password + return self.login_as_admin(password, login_method) + else: + if password is None: + password = self.conf.identity.password + if user is None: + user = self.conf.identity.username + return self.login_as_user(user, password, login_method) + + def login_as_admin(self, password, login_method): + self.username.send_keys(self.conf.identity.admin_username) + self.password.send_keys(password) + login_method() + return system_overviewpage.OverviewPage(self.driver, self.conf) + + def login_as_user(self, user, password, login_method): + self.username.send_keys(user) + self.password.send_keys(password) + login_method() + return compute_overviewpage.OverviewPage(self.driver, self.conf) diff --git a/openstack_dashboard/test/integration_tests/pages/navigation.py b/openstack_dashboard/test/integration_tests/pages/navigation.py new file mode 100644 index 0000000000..72f17cfc56 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/navigation.py @@ -0,0 +1,341 @@ +# 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 functools +import importlib +import json +import types + +from selenium.webdriver.common import by + +from openstack_dashboard.test.integration_tests import config + + +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 + + CONFIG = config.get_config() + PAGES_IMPORT_PATH = [ + "openstack_dashboard.test.integration_tests.pages.%s" + ] + if CONFIG.plugin.is_plugin and CONFIG.plugin.plugin_page_path: + for path in CONFIG.plugin.plugin_page_path: + PAGES_IMPORT_PATH.append(path + ".%s") + + ITEMS = "_" + + CORE_PAGE_STRUCTURE = \ + { + "Project": + { + "Compute": + { + + ITEMS: + ( + "Overview", + "Instances", + "Images", + "Key Pairs", + "Server Groups", + ) + }, + "Volumes": + { + ITEMS: + ( + "Volumes", + "Backups", + "Snapshots", + ) + }, + "Network": + { + ITEMS: + ( + "Network Topology", + "Networks", + "Routers", + "Security Groups", + "Floating IPs", + ) + }, + ITEMS: + ( + "API Access", + ) + }, + "Admin": + { + "Compute": + { + + ITEMS: + ( + "Hypervisors", + "Host Aggregates", + "Instances", + "Flavors", + "Images", + ) + }, + "Volume": + { + ITEMS: + ( + "Volumes", + "Snapshots", + "Volume Types", + "Group Types", + + ) + }, + "Network": + { + ITEMS: + ( + "Networks", + "Routers", + "Floating IPs", + ) + }, + "System": + { + ITEMS: + ( + "Defaults", + "Metadata Definitions", + "System Information" + ) + }, + ITEMS: + ( + "Overview", + ) + }, + + "Identity": + { + ITEMS: + ( + "Projects", + "Users", + "Groups", + "Roles", + "Application Credentials", + ) + }, + "Settings": + { + ITEMS: + ( + "User Settings", + "Change Password", + ) + }, + } + _main_content_locator = (by.By.ID, 'content_body') + + # 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): + content_body = self.driver.find_element(*self._main_content_locator) + content_body.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) + + module = None + # return imported class + for path in self.PAGES_IMPORT_PATH: + try: + module = importlib.import_module(path % + page_cls_path) + break + except ImportError: + pass + if module is None: + raise ImportError("Failed to import module: " + + (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' + """ + if len(self.path) < 3: + path_2_name = list(self.path[-2:]) + else: + path_2_name = list(self.path[-3:]) + name = self.METHOD_NAME_DELIMITER.join(path_2_name) + name = self.METHOD_NAME_PREFIX + name + 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.items(): + rec(sub_item, sub_menus + (sub_menu,)) + elif isinstance(items, (list, 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.CORE_PAGE_STRUCTURE, ()) + plugin_page_structure_strings = cls.CONFIG.plugin.plugin_page_structure + for plugin_ps_string in plugin_page_structure_strings: + plugin_page_structure = json.loads(plugin_ps_string) + rec(plugin_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, Navigation) + + def _go_to_page(self, path): + return Navigation._go_to_page(self, path) + + wrapped_go_to = functools.partialmethod(_go_to_page, path) + setattr(Navigation, inst_method.name, wrapped_go_to) + + @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() diff --git a/openstack_dashboard/test/integration_tests/pages/pageobject.py b/openstack_dashboard/test/integration_tests/pages/pageobject.py new file mode 100644 index 0000000000..8ec949db86 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/pageobject.py @@ -0,0 +1,94 @@ +# 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 urllib import parse + +from openstack_dashboard.test.integration_tests import basewebobject + + +class PageObject(basewebobject.BaseWebObject): + """Base class for page objects.""" + + PARTIAL_LOGIN_URL = 'auth/login' + + def __init__(self, driver, conf): + """Constructor.""" + super().__init__(driver, conf) + self._page_title = None + + @property + def page_title(self): + return self.driver.title + + def is_the_current_page(self, do_assert=False): + found_expected_title = self.page_title.startswith(self._page_title) + if do_assert: + self.assertTrue( + found_expected_title, + "Expected to find %s in page title, instead found: %s" + % (self._page_title, self.page_title)) + return found_expected_title + + @property + def login_url(self): + base_url = self.conf.dashboard.dashboard_url + if not base_url.endswith('/'): + base_url += '/' + return parse.urljoin(base_url, self.PARTIAL_LOGIN_URL) + + def get_url_current_page(self): + return self.driver.current_url + + def close_window(self): + return self.driver.close() + + def is_nth_window_opened(self, n): + return len(self.driver.window_handles) == n + + def switch_window(self, window_name=None, window_index=None): + """Switches focus between the webdriver windows. + + Args: + - window_name: The name of the window to switch to. + - window_index: The index of the window handle to switch to. + If the method is called without arguments it switches to the + last window in the driver window_handles list. + In case only one window exists nothing effectively happens. + Usage: + page.switch_window('_new') + page.switch_window(2) + page.switch_window() + """ + + if window_name is not None and window_index is not None: + raise ValueError("switch_window receives the window's name or " + "the window's index, not both.") + if window_name is not None: + self.driver.switch_to.window(window_name) + elif window_index is not None: + self.driver.switch_to.window( + self.driver.window_handles[window_index]) + else: + self.driver.switch_to.window(self.driver.window_handles[-1]) + + def go_to_previous_page(self): + self.driver.back() + + def go_to_next_page(self): + self.driver.forward() + + def refresh_page(self): + self.driver.refresh() + + def go_to_login_page(self): + self.driver.get(self.login_url) + self.is_the_current_page(do_assert=True) diff --git a/openstack_dashboard/test/integration_tests/pages/project/__init__.py b/openstack_dashboard/test/integration_tests/pages/project/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/test/integration_tests/pages/project/apiaccesspage.py b/openstack_dashboard/test/integration_tests/pages/project/apiaccesspage.py new file mode 100644 index 0000000000..7a4b1652c3 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/project/apiaccesspage.py @@ -0,0 +1,81 @@ +# 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 os import listdir +from os.path import isfile +from os.path import join +from re import search + +from openstack_dashboard.test.integration_tests.pages import basepage +from openstack_dashboard.test.integration_tests.regions import tables + + +class ApiAccessTable(tables.TableRegion): + name = "endpoints" + dropdown = 'Download OpenStack' + + def download_rcfile_dropdown(self): + self.driver.find_element_by_partial_link_text( + self.dropdown).click() + + @tables.bind_table_action('download_openrc_v2') + def download_openstack_rc_v2(self, download_button): + self.download_rcfile_dropdown() + download_button.click() + + @tables.bind_table_action('download_openrc') + def download_openstack_rc_v3(self, download_button): + self.download_rcfile_dropdown() + download_button.click() + + +class ApiaccessPage(basepage.BaseNavigationPage): + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "API Access" + + @property + def apiaccess_table(self): + return ApiAccessTable(self.driver, self.conf) + + def download_openstack_rc_file(self, version, directory, template): + if version == 2: + self.apiaccess_table.download_openstack_rc_v2() + elif version == 3: + self.apiaccess_table.download_openstack_rc_v3() + + def list_of_files(self, directory, template): + return [f for f in listdir(directory) + if (isfile(join(directory, f)) and + f.endswith(template))] + + def get_credentials_from_file(self, version, directory, template): + self._wait_until( + lambda _: len(self.list_of_files(directory, template)) > 0) + file_name = self.list_of_files(directory, template)[0] + with open(join(directory, file_name)) as cred_file: + content = cred_file.read() + username_re = r'export OS_USERNAME="([^"]+)"' + if version == 2: + tenant_name_re = r'export OS_TENANT_NAME="([^"]+)"' + tenant_id_re = r'export OS_TENANT_ID=(.+)' + elif version == 3: + tenant_name_re = r'export OS_PROJECT_NAME="([^"]+)"' + tenant_id_re = r'export OS_PROJECT_ID=(.+)' + username = search(username_re, content).group(1) + tenant_name = search(tenant_name_re, content).group(1) + tenant_id = search(tenant_id_re, content).group(1) + cred_dict = {'OS_USERNAME': username, + 'OS_TENANT_NAME': tenant_name, + 'OS_TENANT_ID': tenant_id} + return cred_dict diff --git a/openstack_dashboard/test/integration_tests/pages/project/compute/__init__.py b/openstack_dashboard/test/integration_tests/pages/project/compute/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/test/integration_tests/pages/project/compute/imagespage.py b/openstack_dashboard/test/integration_tests/pages/project/compute/imagespage.py new file mode 100644 index 0000000000..c3795a9ecd --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/project/compute/imagespage.py @@ -0,0 +1,314 @@ +# 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.regions import forms +from openstack_dashboard.test.integration_tests.regions import menus +from openstack_dashboard.test.integration_tests.regions import tables + +from openstack_dashboard.test.integration_tests.pages.project.compute.\ + instancespage import InstancesPage + + +# TODO(bpokorny): Set the default source back to 'url' once Glance removes +# the show_multiple_locations option, and if the default devstack policies +# allow setting locations. +DEFAULT_IMAGE_SOURCE = 'file' +DEFAULT_IMAGE_FORMAT = 'string:raw' +DEFAULT_ACCESSIBILITY = False +DEFAULT_PROTECTION = False +IMAGES_TABLE_NAME_COLUMN = 'Name' +IMAGES_TABLE_STATUS_COLUMN = 'Status' +IMAGES_TABLE_FORMAT_COLUMN = 'Disk Format' + + +class ImagesTable(tables.TableRegionNG): + name = "images" + + CREATE_IMAGE_FORM_FIELDS = ( + "name", "description", "image_file", "kernel", "ramdisk", "format", + "architecture", "min_disk", "min_ram", "visibility", "protected" + ) + + CREATE_VOLUME_FROM_IMAGE_FORM_FIELDS = ( + "name", "description", + "volume_size", + "availability_zone") + + LAUNCH_INSTANCE_FORM_FIELDS = ( + ("name", "count", "availability_zone"), + ("boot_source_type", "volume_size"), + { + 'flavor': menus.InstanceFlavorMenuRegion + }, + { + 'network': menus.InstanceAvailableResourceMenuRegion + }, + ) + + EDIT_IMAGE_FORM_FIELDS = ( + "name", "description", "format", "min_disk", + "min_ram", "visibility", "protected" + ) + + @tables.bind_table_action_ng('Create Image') + def create_image(self, create_button): + create_button.click() + return forms.FormRegionNG(self.driver, self.conf, + field_mappings=self.CREATE_IMAGE_FORM_FIELDS) + + @tables.bind_table_action_ng('Delete Images') + def delete_image(self, delete_button): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + @tables.bind_row_action_ng('Delete Image') + def delete_image_via_row_action(self, delete_button, row): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + @tables.bind_row_action_ng('Edit Image') + def edit_image(self, edit_button, row): + edit_button.click() + return forms.FormRegionNG(self.driver, self.conf, + field_mappings=self.EDIT_IMAGE_FORM_FIELDS) + + @tables.bind_row_action_ng('Update Metadata') + def update_metadata(self, metadata_button, row): + metadata_button.click() + return forms.MetadataFormRegion(self.driver, self.conf) + + @tables.bind_row_anchor_column_ng(IMAGES_TABLE_NAME_COLUMN) + def go_to_image_description_page(self, row_link, row): + row_link.click() + return forms.ItemTextDescription(self.driver, self.conf) + + @tables.bind_row_action_ng('Create Volume') + def create_volume(self, create_volume, row): + create_volume.click() + return forms.FormRegion( + self.driver, self.conf, + field_mappings=self.CREATE_VOLUME_FROM_IMAGE_FORM_FIELDS) + + @tables.bind_row_action_ng('Launch') + def launch_instance(self, launch_instance, row): + launch_instance.click() + return forms.WizardFormRegion( + self.driver, self.conf, self.LAUNCH_INSTANCE_FORM_FIELDS) + + +class ImagesPage(basepage.BaseNavigationPage): + _resource_page_header_locator = (by.By.CSS_SELECTOR, + 'hz-resource-panel hz-page-header h1') + _default_form_locator = (by.By.CSS_SELECTOR, 'div.modal-dialog') + _search_field_locator = (by.By.CSS_SELECTOR, + 'magic-search.form-control input.search-input') + _search_button_locator = (by.By.CSS_SELECTOR, + 'hz-magic-search-bar span.fa-search') + _search_option_locator = (by.By.CSS_SELECTOR, + 'magic-search.form-control span.search-entry') + _search_name_locator_filled = ( + by.By.XPATH, + "//*[@id='imageForm-name'][contains(@class,'ng-not-empty')]" + ) + _search_checkbox_loaded = ( + by.By.CSS_SELECTOR, + "td .themable-checkbox [type='checkbox'] + label[for*='Zactive']" + ) + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "Images" + + @property + def header(self): + return self._get_element(*self._resource_page_header_locator) + + @property + def images_table(self): + return ImagesTable(self.driver, self.conf) + + def wizard_getter(self): + return self._get_element(*self._default_form_locator) + + def _get_row_with_image_name(self, name): + self.wait_until_element_is_visible(self._search_checkbox_loaded) + + return self.images_table.get_row(IMAGES_TABLE_NAME_COLUMN, name) + + def create_image(self, name, description=None, + image_source_type=DEFAULT_IMAGE_SOURCE, + location=None, image_file=None, + image_format=DEFAULT_IMAGE_FORMAT): + create_image_form = self.images_table.create_image() + create_image_form.name.text = name + if description is not None: + create_image_form.description.text = description + # TODO(bpokorny): Add this back once the show_multiple_locations + # option is removed from Glance + # create_image_form.source_type.value = image_source_type + if image_source_type == 'url': + if location is None: + create_image_form.image_url.text = \ + self.conf.image.http_image + else: + create_image_form.image_url.text = location + else: + create_image_form.image_file.choose(image_file) + create_image_form.format.value = image_format + create_image_form.submit() + self.wait_till_element_disappears(self.wizard_getter) + + def delete_image(self, name): + row = self._get_row_with_image_name(name) + row.mark() + confirm_delete_images_form = self.images_table.delete_image() + confirm_delete_images_form.submit() + self.wait_till_spinner_disappears() + + def delete_images(self, images_names): + for image_name in images_names: + self._get_row_with_image_name(image_name).mark() + confirm_delete_images_form = self.images_table.delete_image() + confirm_delete_images_form.submit() + self.wait_till_spinner_disappears() + + def edit_image(self, name, new_name=None, description=None, + min_disk=None, min_ram=None, + visibility=None, protected=None): + row = self._get_row_with_image_name(name) + confirm_edit_images_form = self.images_table.edit_image(row) + self.wait_until_element_is_visible(self._search_name_locator_filled) + + if new_name is not None: + confirm_edit_images_form.name.text = new_name + + if description is not None: + confirm_edit_images_form.description.text = description + + if min_disk is not None: + confirm_edit_images_form.min_disk.value = min_disk + + if min_ram is not None: + confirm_edit_images_form.min_ram.value = min_ram + + if visibility is not None: + if visibility is True: + confirm_edit_images_form.visibility.pick('Shared') + elif visibility is False: + confirm_edit_images_form.visibility.pick('Private') + + if protected is not None: + if protected is True: + confirm_edit_images_form.protected.pick('Yes') + elif protected is False: + confirm_edit_images_form.protected.pick('No') + + confirm_edit_images_form.submit() + self.wait_till_element_disappears(self.wizard_getter) + + def delete_image_via_row_action(self, name): + row = self._get_row_with_image_name(name) + delete_image_form = self.images_table.delete_image_via_row_action(row) + delete_image_form.submit() + + def add_custom_metadata(self, name, metadata): + row = self._get_row_with_image_name(name) + update_metadata_form = self.images_table.update_metadata(row) + for field_name, value in metadata.items(): + update_metadata_form.add_custom_field(field_name, value) + update_metadata_form.submit() + + def check_image_details(self, name, dict_with_details): + row = self._get_row_with_image_name(name) + matches = [] + description_page = self.images_table.go_to_image_description_page(row) + content = description_page.get_content() + + for name, value in content.items(): + if name in dict_with_details: + if dict_with_details[name] in value: + matches.append(True) + return matches + + def is_image_present(self, name): + return bool(self._get_row_with_image_name(name)) + + def is_image_active(self, name): + def cell_getter(): + row = self._get_row_with_image_name(name) + return row and row.cells[IMAGES_TABLE_STATUS_COLUMN] + + return bool(self.images_table.wait_cell_status(cell_getter, 'Active')) + + def wait_until_image_active(self, name): + self._wait_until(lambda x: self.is_image_active(name)) + + def wait_until_image_present(self, name): + self._wait_until(lambda x: self.is_image_present(name)) + + def get_image_format(self, name): + row = self._get_row_with_image_name(name) + return row.cells[IMAGES_TABLE_FORMAT_COLUMN].text + + def filter(self, value): + self._set_search_field(value) + self._click_search_btn() + self.driver.implicitly_wait(5) + + def _set_search_field(self, value): + srch_field = self._get_element(*self._search_field_locator) + srch_field.clear() + srch_field.send_keys(value) + + def _click_search_btn(self): + btn = self._get_element(*self._search_button_locator) + btn.click() + + def create_volume_from_image(self, name, volume_name=None, + description=None, + volume_size=None): + row = self._get_row_with_image_name(name) + create_volume_form = self.images_table.create_volume(row) + if volume_name is not None: + create_volume_form.name.text = volume_name + if description is not None: + create_volume_form.description.text = description + create_volume_form.image_source = name + create_volume_form.volume_size.value = volume_size if volume_size \ + else self.conf.volume.volume_size + create_volume_form.availability_zone.value = \ + self.conf.launch_instances.available_zone + create_volume_form.submit() + self.wait_till_element_disappears(self.wizard_getter) + + def launch_instance_from_image(self, name, instance_name, + instance_count=1, flavor=None): + instance_page = InstancesPage(self.driver, self.conf) + row = self._get_row_with_image_name(name) + instance_form = self.images_table.launch_instance(row) + instance_form.availability_zone.value = \ + self.conf.launch_instances.available_zone + instance_form.name.text = instance_name + instance_form.count.value = instance_count + instance_form.switch_to(instance_page.SOURCE_STEP_INDEX) + instance_page.vol_delete_on_instance_delete_click() + instance_form.switch_to(instance_page.FLAVOR_STEP_INDEX) + if flavor is None: + flavor = self.conf.launch_instances.flavor + instance_form.flavor.transfer_available_resource(flavor) + instance_form.switch_to(instance_page.NETWORKS_STEP_INDEX) + instance_form.network.transfer_available_resource( + instance_page.DEFAULT_NETWORK_TYPE) + instance_form.submit() + instance_form.wait_till_wizard_disappears() diff --git a/openstack_dashboard/test/integration_tests/pages/project/compute/instancespage.py b/openstack_dashboard/test/integration_tests/pages/project/compute/instancespage.py new file mode 100644 index 0000000000..e7ddf66904 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/project/compute/instancespage.py @@ -0,0 +1,203 @@ +# 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 time + +import netaddr + +from selenium.common import exceptions +from selenium.webdriver.common import by + +from openstack_dashboard.test.integration_tests.pages import basepage +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import menus +from openstack_dashboard.test.integration_tests.regions import tables + + +class InstancesTable(tables.TableRegion): + name = "instances" + LAUNCH_INSTANCE_FORM_FIELDS = ( + ("name", "count", "availability_zone"), + ("boot_source_type", "volume_size"), + { + 'flavor': menus.InstanceFlavorMenuRegion + }, + { + 'network': menus.InstanceAvailableResourceMenuRegion + }, + ) + + @tables.bind_table_action('launch-ng') + def launch_instance(self, launch_button): + launch_button.click() + return forms.WizardFormRegion(self.driver, self.conf, + self.LAUNCH_INSTANCE_FORM_FIELDS) + + @tables.bind_table_action('delete') + def delete_instance(self, delete_button): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + +class InstancesPage(basepage.BaseNavigationPage): + + DEFAULT_FLAVOR = 'm1.tiny' + DEFAULT_COUNT = 1 + DEFAULT_BOOT_SOURCE = 'Image' + DEFAULT_VOLUME_NAME = None + DEFAULT_SNAPSHOT_NAME = None + DEFAULT_VOLUME_SNAPSHOT_NAME = None + DEFAULT_VOL_DELETE_ON_INSTANCE_DELETE = True + DEFAULT_SECURITY_GROUP = True + DEFAULT_NETWORK_TYPE = 'shared' + + INSTANCES_TABLE_NAME_COLUMN = 'Instance Name' + INSTANCES_TABLE_STATUS_COLUMN = 'Status' + INSTANCES_TABLE_IP_COLUMN = 'IP Address' + INSTANCES_TABLE_IMAGE_NAME_COLUMN = 'Image Name' + + SOURCE_STEP_INDEX = 1 + FLAVOR_STEP_INDEX = 2 + NETWORKS_STEP_INDEX = 3 + + _search_state_active = ( + by.By.XPATH, + "//*[contains(@class,'normal_column')][contains(text(),'Active')]" + ) + + def __init__(self, driver=None, conf=None): + super().__init__(driver, conf) + self._page_title = "Instances" + + def _get_row_with_instance_name(self, name): + for attempt in range(4): + row = self.instances_table.get_row( + self.INSTANCES_TABLE_NAME_COLUMN, name) + if row is not None: + return row + else: + time.sleep(0.5) + return None + + def _get_rows_with_instances_names(self, names): + return [ + self.instances_table.get_row( + self.INSTANCES_TABLE_IMAGE_NAME_COLUMN, n) for n in names + ] + + @property + def instances_table(self): + return InstancesTable(self.driver, self.conf) + + def is_instance_present(self, name): + return bool(self._get_row_with_instance_name(name)) + + def create_instance( + self, instance_name, + available_zone=None, + instance_count=DEFAULT_COUNT, + flavor=DEFAULT_FLAVOR, + boot_source=DEFAULT_BOOT_SOURCE, + network_type=DEFAULT_NETWORK_TYPE, + source_name=None, + device_size=None, + vol_delete_on_instance_delete=DEFAULT_VOL_DELETE_ON_INSTANCE_DELETE + ): + if not available_zone: + available_zone = self.conf.launch_instances.available_zone + instance_form = self.instances_table.launch_instance() + instance_form.availability_zone.value = available_zone + instance_form.name.text = instance_name + instance_form.count.value = instance_count + instance_form.switch_to(self.SOURCE_STEP_INDEX) + instance_form.boot_source_type.text = boot_source + boot_source = self._get_source_name(instance_form, boot_source, + self.conf.launch_instances) + if not source_name: + source_name = boot_source + menus.InstanceAvailableResourceMenuRegion( + self.driver, self.conf).transfer_available_resource(source_name) + if device_size: + instance_form.volume_size.value = device_size + if vol_delete_on_instance_delete: + self.vol_delete_on_instance_delete_click() + instance_form.switch_to(self.FLAVOR_STEP_INDEX) + instance_form.flavor.transfer_available_resource(flavor) + instance_form.switch_to(self.NETWORKS_STEP_INDEX) + instance_form.network.transfer_available_resource( + self.DEFAULT_NETWORK_TYPE) + instance_form.submit() + instance_form.wait_till_wizard_disappears() + + def vol_delete_on_instance_delete_click(self): + locator = ( + by.By.XPATH, + '//label[contains(@ng-model, "vol_delete_on_instance_delete")]') + elements = self._get_elements(*locator) + for ele in elements: + if ele.text == 'Yes': + ele.click() + + def delete_instance(self, name): + row = self._get_row_with_instance_name(name) + row.mark() + confirm_delete_instances_form = self.instances_table.delete_instance() + confirm_delete_instances_form.submit() + + def delete_instances(self, instances_names): + for instance_name in instances_names: + self._get_row_with_instance_name(instance_name).mark() + confirm_delete_instances_form = self.instances_table.delete_instance() + confirm_delete_instances_form.submit() + + def is_instance_deleted(self, name): + return self.instances_table.is_row_deleted( + lambda: self._get_row_with_instance_name(name)) + + def are_instances_deleted(self, instances_names): + return self.instances_table.are_rows_deleted( + lambda: self._get_rows_with_instances_names(instances_names)) + + def is_instance_active(self, name): + def cell_getter(): + row = self._get_row_with_instance_name(name) + return row and row.cells[self.INSTANCES_TABLE_STATUS_COLUMN] + + try: + self.wait_until_element_is_visible(self._search_state_active) + except exceptions.TimeoutException: + return False + status = self.instances_table.wait_cell_status(cell_getter, + ('Active', 'Error')) + return status == 'Active' + + def _get_source_name(self, instance, boot_source, conf): + if 'Image' in boot_source: + return conf.image_name + elif boot_source == 'Volume': + return instance.volume_id, self.DEFAULT_VOLUME_NAME + elif boot_source == 'Instance Snapshot': + return instance.instance_snapshot_id, self.DEFAULT_SNAPSHOT_NAME + elif 'Volume Snapshot' in boot_source: + return (instance.volume_snapshot_id, + self.DEFAULT_VOLUME_SNAPSHOT_NAME) + + def get_image_name(self, instance_name): + row = self._get_row_with_instance_name(instance_name) + return row.cells[self.INSTANCES_TABLE_IMAGE_NAME_COLUMN].text + + def get_fixed_ipv4(self, name): + row = self._get_row_with_instance_name(name) + ips = row.cells[self.INSTANCES_TABLE_IP_COLUMN].text + for ip in ips.split(','): + if netaddr.valid_ipv4(ip): + return ip diff --git a/openstack_dashboard/test/integration_tests/pages/project/compute/keypairspage.py b/openstack_dashboard/test/integration_tests/pages/project/compute/keypairspage.py new file mode 100644 index 0000000000..4135772e5e --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/project/compute/keypairspage.py @@ -0,0 +1,82 @@ +# 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 openstack_dashboard.test.integration_tests.pages import basepage +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables + + +class KeypairsTable(tables.TableRegion): + name = "keypairs" + CREATE_KEY_PAIR_FORM_FIELDS = ('name',) + + @tables.bind_table_action('create') + def create_keypair(self, create_button): + create_button.click() + return forms.FormRegion( + self.driver, self.conf, + field_mappings=self.CREATE_KEY_PAIR_FORM_FIELDS) + + @tables.bind_row_action('delete') + def delete_keypair(self, delete_button, row): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + @tables.bind_table_action('delete') + def delete_keypairs(self, delete_button): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + +class KeypairsPage(basepage.BaseNavigationPage): + + KEY_PAIRS_TABLE_ACTIONS = ("create", "import", "delete") + KEY_PAIRS_TABLE_ROW_ACTION = "delete" + KEY_PAIRS_TABLE_NAME_COLUMN = 'Name' + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "Key Pairs" + + def _get_row_with_keypair_name(self, name): + return self.keypairs_table.get_row(self.KEY_PAIRS_TABLE_NAME_COLUMN, + name) + + @property + def keypairs_table(self): + return KeypairsTable(self.driver, self.conf) + + @property + def delete_keypair_form(self): + return forms.BaseFormRegion(self.driver, self.conf, None) + + def is_keypair_present(self, name): + return bool(self._get_row_with_keypair_name(name)) + + def create_keypair(self, keypair_name): + create_keypair_form = self.keypairs_table.create_keypair() + create_keypair_form.name.text = keypair_name + create_keypair_form.submit() + + def delete_keypair(self, name): + row = self._get_row_with_keypair_name(name) + delete_keypair_form = self.keypairs_table.delete_keypair(row) + delete_keypair_form.submit() + + def delete_keypairs(self, name): + row = self._get_row_with_keypair_name(name) + row.mark() + delete_keypair_form = self.keypairs_table.delete_keypairs() + delete_keypair_form.submit() diff --git a/openstack_dashboard/test/integration_tests/pages/project/compute/overviewpage.py b/openstack_dashboard/test/integration_tests/pages/project/compute/overviewpage.py new file mode 100644 index 0000000000..149d8d957f --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/project/compute/overviewpage.py @@ -0,0 +1,37 @@ +# 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.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables + + +class UsageTable(tables.TableRegion): + name = 'project_usage' + + +class OverviewPage(basepage.BaseNavigationPage): + _date_form_locator = (by.By.ID, 'date_form') + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = 'Instance Overview' + + @property + def usage_table(self): + return UsageTable(self.driver, self.conf) + + @property + def date_form(self): + src_elem = self._get_element(*self._date_form_locator) + return forms.DateFormRegion(self.driver, self.conf, src_elem) diff --git a/openstack_dashboard/test/integration_tests/pages/project/compute/servergroupspage.py b/openstack_dashboard/test/integration_tests/pages/project/compute/servergroupspage.py new file mode 100644 index 0000000000..846fcab868 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/project/compute/servergroupspage.py @@ -0,0 +1,20 @@ +# 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 openstack_dashboard.test.integration_tests.pages import basepage + + +class ServergroupsPage(basepage.BaseNavigationPage): + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = 'Server Groups' diff --git a/openstack_dashboard/test/integration_tests/pages/project/network/__init__.py b/openstack_dashboard/test/integration_tests/pages/project/network/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/test/integration_tests/pages/project/network/floatingipspage.py b/openstack_dashboard/test/integration_tests/pages/project/network/floatingipspage.py new file mode 100644 index 0000000000..180b68f405 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/project/network/floatingipspage.py @@ -0,0 +1,107 @@ +# Copyrigh:t 2015 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. + +import re + +from selenium.webdriver.common import by + +from openstack_dashboard.test.integration_tests.pages import basepage +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables + + +class FloatingIPTable(tables.TableRegion): + name = 'floating_ips' + FLOATING_IP_ASSOCIATIONS = ( + ("ip_id", "port_id")) + + @tables.bind_table_action('allocate') + def allocate_ip(self, allocate_button): + allocate_button.click() + self.wait_till_spinner_disappears() + return forms.BaseFormRegion(self.driver, self.conf) + + @tables.bind_table_action('release') + def release_ip(self, release_button): + release_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + @tables.bind_row_action('associate') + def associate_ip(self, associate_button, row): + associate_button.click() + return forms.FormRegion(self.driver, self.conf, + field_mappings=self.FLOATING_IP_ASSOCIATIONS) + + @tables.bind_row_action('disassociate') + def disassociate_ip(self, disassociate_button, row): + disassociate_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + +class FloatingipsPage(basepage.BaseNavigationPage): + FLOATING_IPS_TABLE_IP_COLUMN = 'IP Address' + FLOATING_IPS_TABLE_FIXED_IP_COLUMN = 'Mapped Fixed IP Address' + + _floatingips_fadein_popup_locator = ( + by.By.CSS_SELECTOR, '.alert.alert-success.alert-dismissable.fade.in>p') + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "Floating IPs" + + def _get_row_with_floatingip(self, floatingip): + return self.floatingips_table.get_row( + self.FLOATING_IPS_TABLE_IP_COLUMN, floatingip) + + @property + def floatingips_table(self): + return FloatingIPTable(self.driver, self.conf) + + def allocate_floatingip(self): + floatingip_form = self.floatingips_table.allocate_ip() + floatingip_form.submit() + ip = re.compile(r'(([2][5][0-5]\.)|([2][0-4][0-9]\.)' + r'|([0-1]?[0-9]?[0-9]\.)){3}(([2][5][0-5])|' + r'([2][0-4][0-9])|([0-1]?[0-9]?[0-9]))') + match = ip.search((self._get_element( + *self._floatingips_fadein_popup_locator)).text) + floatingip = str(match.group()) + return floatingip + + def release_floatingip(self, floatingip): + row = self._get_row_with_floatingip(floatingip) + row.mark() + modal_confirmation_form = self.floatingips_table.release_ip() + modal_confirmation_form.submit() + + def is_floatingip_present(self, floatingip): + return bool(self._get_row_with_floatingip(floatingip)) + + def associate_floatingip(self, floatingip, instance_name=None, + instance_ip=None): + row = self._get_row_with_floatingip(floatingip) + floatingip_form = self.floatingips_table.associate_ip(row) + floatingip_form.port_id.text = "{}: {}".format(instance_name, + instance_ip) + floatingip_form.submit() + + def disassociate_floatingip(self, floatingip): + row = self._get_row_with_floatingip(floatingip) + floatingip_form = self.floatingips_table.disassociate_ip(row) + floatingip_form.submit() + + def get_fixed_ip(self, floatingip): + row = self._get_row_with_floatingip(floatingip) + return row.cells[self.FLOATING_IPS_TABLE_FIXED_IP_COLUMN].text diff --git a/openstack_dashboard/test/integration_tests/pages/project/network/networkoverviewpage.py b/openstack_dashboard/test/integration_tests/pages/project/network/networkoverviewpage.py new file mode 100644 index 0000000000..ef63d7cce9 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/project/network/networkoverviewpage.py @@ -0,0 +1,37 @@ +# 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 + + +class NetworkOverviewPage(basepage.BaseNavigationPage): + + _network_dd_name_locator = (by.By.CSS_SELECTOR, 'dt[title*="Name"]+dd') + + _network_dd_status_locator = (by.By.CSS_SELECTOR, 'dt[title*="Status"]+dd') + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = 'Network Details' + self._external_network = conf.network.external_network + + def is_network_name_present(self, network_name=None): + if network_name is None: + network_name = self._external_network + dd_text = self._get_element(*self._network_dd_name_locator).text + return dd_text == network_name + + def is_network_status(self, status): + dd_text = self._get_element(*self._network_dd_status_locator).text + return dd_text == status diff --git a/openstack_dashboard/test/integration_tests/pages/project/network/networkspage.py b/openstack_dashboard/test/integration_tests/pages/project/network/networkspage.py new file mode 100644 index 0000000000..b79bd3cd3d --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/project/network/networkspage.py @@ -0,0 +1,124 @@ +# 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 openstack_dashboard.test.integration_tests.pages import basepage +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables + + +class NetworksTable(tables.TableRegion): + name = "networks" + CREATE_NETWORK_FORM_FIELDS = (("net_name", "admin_state", + "with_subnet", "az_hints"), + ("subnet_name", "cidr", "ip_version", + "gateway_ip", "no_gateway"), + ("enable_dhcp", "allocation_pools", + "dns_nameservers", "host_routes")) + + @tables.bind_table_action('create') + def create_network(self, create_button): + create_button.click() + return forms.TabbedFormRegion(self.driver, self.conf, + self.CREATE_NETWORK_FORM_FIELDS) + + @tables.bind_table_action('delete') + def delete_network(self, delete_button): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + +class NetworksPage(basepage.BaseNavigationPage): + DEFAULT_ADMIN_STATE = 'True' + DEFAULT_CREATE_SUBNET = True + DEFAULT_IP_VERSION = '4' + DEFAULT_DISABLE_GATEWAY = False + DEFAULT_ENABLE_DHCP = True + NETWORKS_TABLE_NAME_COLUMN = 'Name' + NETWORKS_TABLE_STATUS_COLUMN = 'Status' + SUBNET_TAB_INDEX = 1 + DETAILS_TAB_INDEX = 2 + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "Networks" + + def _get_row_with_network_name(self, name): + return self.networks_table.get_row( + self.NETWORKS_TABLE_NAME_COLUMN, name) + + @property + def is_admin(self): + return False + + @property + def networks_table(self): + return NetworksTable(self.driver, self.conf) + + def create_network(self, network_name, subnet_name, + admin_state=DEFAULT_ADMIN_STATE, + create_subnet=DEFAULT_CREATE_SUBNET, + network_address=None, ip_version=DEFAULT_IP_VERSION, + gateway_ip=None, + disable_gateway=DEFAULT_DISABLE_GATEWAY, + enable_dhcp=DEFAULT_ENABLE_DHCP, allocation_pools=None, + dns_name_servers=None, host_routes=None, + project='admin', net_type='Local'): + create_network_form = self.networks_table.create_network() + if self.is_admin: + create_network_form.network_type.text = net_type + create_network_form.tenant_id.text = project + create_network_form.name.text = network_name + else: + create_network_form.net_name.text = network_name + create_network_form.admin_state.value = admin_state + if not create_subnet: + create_network_form.with_subnet.unmark() + else: + create_network_form.switch_to(self.SUBNET_TAB_INDEX) + create_network_form.subnet_name.text = subnet_name + if network_address is None: + network_address = self.conf.network.network_cidr + create_network_form.cidr.text = network_address + + create_network_form.ip_version.value = ip_version + if gateway_ip is not None: + create_network_form.gateway_ip.text = gateway_ip + if disable_gateway: + create_network_form.disable_gateway.mark() + + create_network_form.switch_to(self.DETAILS_TAB_INDEX) + if not enable_dhcp: + create_network_form.enable_dhcp.unmark() + if allocation_pools is not None: + create_network_form.allocation_pools.text = allocation_pools + if dns_name_servers is not None: + create_network_form.dns_nameservers.text = dns_name_servers + if host_routes is not None: + create_network_form.host_routes.text = host_routes + create_network_form.submit() + + def delete_network(self, name): + row = self._get_row_with_network_name(name) + row.mark() + confirm_delete_networks_form = self.networks_table.delete_network() + confirm_delete_networks_form.submit() + + def is_network_present(self, name): + return bool(self._get_row_with_network_name(name)) + + def is_network_active(self, name): + def cell_getter(): + row = self._get_row_with_network_name(name) + return row and row.cells[self.NETWORKS_TABLE_STATUS_COLUMN] + + return bool(self.networks_table.wait_cell_status(cell_getter, + 'Active')) diff --git a/openstack_dashboard/test/integration_tests/pages/project/network/networktopologypage.py b/openstack_dashboard/test/integration_tests/pages/project/network/networktopologypage.py new file mode 100644 index 0000000000..c3756246f3 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/project/network/networktopologypage.py @@ -0,0 +1,20 @@ +# 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 openstack_dashboard.test.integration_tests.pages import basepage + + +class NetworktopologyPage(basepage.BaseNavigationPage): + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = 'Network Topology' diff --git a/openstack_dashboard/test/integration_tests/pages/project/network/routerinterfacespage.py b/openstack_dashboard/test/integration_tests/pages/project/network/routerinterfacespage.py new file mode 100644 index 0000000000..e8738d37cf --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/project/network/routerinterfacespage.py @@ -0,0 +1,111 @@ +# 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.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables + + +class InterfacesTable(tables.TableRegion): + name = "interfaces" + CREATE_INTERFACE_FORM_FIELDS = ("subnet_id", "ip_address") + + @tables.bind_table_action('create') + def create_interface(self, create_button): + create_button.click() + return forms.FormRegion( + self.driver, + self.conf, + field_mappings=self.CREATE_INTERFACE_FORM_FIELDS) + + @tables.bind_table_action('delete') + def delete_interface(self, delete_button): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + @tables.bind_row_action('delete') + def delete_interface_by_row_action(self, delete_button, row): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + +class RouterInterfacesPage(basepage.BaseNavigationPage): + + INTERFACES_TABLE_STATUS_COLUMN = 'Status' + INTERFACES_TABLE_NAME_COLUMN = 'Name' + INTERFACES_TABLE_FIXED_IPS_COLUMN = 'Fixed IPs' + DEFAULT_IPv4_ADDRESS = '10.100.0.1' + _interface_subnet_selector = (by.By.CSS_SELECTOR, 'div > .themable-select') + _breadcrumb_routers_locator = ( + by.By.CSS_SELECTOR, + 'ol.breadcrumb>li>' + 'a[href*="/project/routers"]') + + def __init__(self, driver, conf, router_name): + super().__init__(driver, conf) + self._page_title = router_name + + def _get_row_with_interface_name(self, name): + return self.interfaces_table.get_row(self.INTERFACES_TABLE_NAME_COLUMN, + name) + + def _get_row_with_ip_address(self): + return self.interfaces_table.get_row( + self.INTERFACES_TABLE_FIXED_IPS_COLUMN, self.DEFAULT_IPv4_ADDRESS) + + @property + def subnet_selector(self): + src_elem = self._get_element(*self._interface_subnet_selector) + return forms.ThemableSelectFormFieldRegion( + self.driver, + self.conf, + src_elem=src_elem, + strict_options_match=False) + + @property + def interfaces_table(self): + return InterfacesTable(self.driver, self.conf) + + @property + def interface_name(self): + row = self._get_row_with_ip_address() + return row.cells[self.INTERFACES_TABLE_NAME_COLUMN].text + + def switch_to_routers_page(self): + self._get_element(*self._breadcrumb_routers_locator).click() + + def create_interface(self, subnet): + interface_form = self.interfaces_table.create_interface() + self.subnet_selector.text = subnet + interface_form.ip_address.text = self.DEFAULT_IPv4_ADDRESS + interface_form.submit() + + def delete_interface(self, interface_name): + row = self._get_row_with_interface_name(interface_name) + row.mark() + confirm_delete_interface_form = self.interfaces_table.\ + delete_interface() + confirm_delete_interface_form.submit() + + def delete_interface_by_row_action(self, interface_name): + row = self._get_row_with_interface_name(interface_name) + confirm_delete_interface = self.interfaces_table.\ + delete_interface_by_row_action(row) + confirm_delete_interface.submit() + + def is_interface_present(self, interface_name): + return bool(self._get_row_with_interface_name(interface_name)) + + def is_interface_status(self, interface_name, status): + row = self._get_row_with_interface_name(interface_name) + return row.cells[self.INTERFACES_TABLE_STATUS_COLUMN].text == status diff --git a/openstack_dashboard/test/integration_tests/pages/project/network/routeroverviewpage.py b/openstack_dashboard/test/integration_tests/pages/project/network/routeroverviewpage.py new file mode 100644 index 0000000000..bed0b5a274 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/project/network/routeroverviewpage.py @@ -0,0 +1,42 @@ +# 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.network\ + .networkoverviewpage import NetworkOverviewPage + + +class RouterOverviewPage(basepage.BaseNavigationPage): + + _network_link_locator = (by.By.CSS_SELECTOR, + 'hr+dl.dl-horizontal>dt:nth-child(3)+dd>a') + + def __init__(self, driver, conf, router_name): + super().__init__(driver, conf) + self._page_title = router_name + + def is_router_name_present(self, router_name): + dd_text = self._get_element(by.By.XPATH, + "//dd[.='{0}']".format(router_name)).text + return dd_text == router_name + + def is_router_status(self, status): + dd_text = self._get_element(by.By.XPATH, + "//dd[.='{0}']".format(status)).text + return dd_text == status + + def go_to_router_network(self): + self._get_element(*self._network_link_locator).click() + return NetworkOverviewPage(self.driver, self.conf) diff --git a/openstack_dashboard/test/integration_tests/pages/project/network/routerspage.py b/openstack_dashboard/test/integration_tests/pages/project/network/routerspage.py new file mode 100644 index 0000000000..23c31d788d --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/project/network/routerspage.py @@ -0,0 +1,149 @@ +# 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.common import exceptions + +from selenium.webdriver.common import by + +from openstack_dashboard.test.integration_tests.pages import basepage +from openstack_dashboard.test.integration_tests.pages.project.network.\ + routerinterfacespage import RouterInterfacesPage +from openstack_dashboard.test.integration_tests.pages.project.network\ + .routeroverviewpage import RouterOverviewPage +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables + + +class RoutersTable(tables.TableRegion): + name = "routers" + CREATE_ROUTER_FORM_FIELDS = ("name", "admin_state_up", "external_network") + SET_GATEWAY_FORM_FIELDS = ("network_id",) + + @tables.bind_table_action('create') + def create_router(self, create_button): + create_button.click() + return forms.FormRegion(self.driver, + self.conf, + field_mappings=self.CREATE_ROUTER_FORM_FIELDS) + + @tables.bind_table_action('delete') + def delete_router(self, delete_button): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + @tables.bind_row_action('clear') + def clear_gateway(self, clear_gateway_button, row): + clear_gateway_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + @tables.bind_row_action('setgateway') + def set_gateway(self, set_gateway_button, row): + set_gateway_button.click() + return forms.FormRegion(self.driver, + self.conf, + field_mappings=self.SET_GATEWAY_FORM_FIELDS) + + +class RoutersPage(basepage.BaseNavigationPage): + + DEFAULT_ADMIN_STATE_UP = 'True' + ROUTERS_TABLE_NAME_COLUMN = 'Name' + ROUTERS_TABLE_STATUS_COLUMN = 'Status' + ROUTERS_TABLE_NETWORK_COLUMN = 'External Network' + + _interfaces_tab_locator = (by.By.CSS_SELECTOR, + 'a[href*="tab=router_details__interfaces"]') + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "Routers" + self._external_network = conf.network.external_network + + def _get_row_with_router_name(self, name): + return self.routers_table.get_row(self.ROUTERS_TABLE_NAME_COLUMN, name) + + @property + def routers_table(self): + return RoutersTable(self.driver, self.conf) + + def create_router(self, name, admin_state_up=DEFAULT_ADMIN_STATE_UP): + create_router_form = self.routers_table.create_router() + create_router_form.name.text = name + create_router_form.admin_state_up.value = admin_state_up + create_router_form.external_network.text = self._external_network + create_router_form.submit() + + def set_gateway(self, router_id): + row = self._get_row_with_router_name(router_id) + set_gateway_form = self.routers_table.set_gateway(row) + set_gateway_form.network_id.text = self._external_network + set_gateway_form.submit() + + def clear_gateway(self, name): + row = self._get_row_with_router_name(name) + confirm_clear_gateway_form = self.routers_table.clear_gateway(row) + confirm_clear_gateway_form.submit() + + def delete_router(self, name): + row = self._get_row_with_router_name(name) + row.mark() + confirm_delete_routers_form = self.routers_table.delete_router() + confirm_delete_routers_form.submit() + + def is_router_present(self, name): + return bool(self._get_row_with_router_name(name)) + + def is_router_active(self, name): + row = self._get_row_with_router_name(name) + + def cell_getter(): + return row.cells[self.ROUTERS_TABLE_STATUS_COLUMN] + + try: + self._wait_till_text_present_in_element(cell_getter, 'Active') + except exceptions.TimeoutException: + return False + return True + + def is_gateway_cleared(self, name): + row = self._get_row_with_router_name(name) + + def cell_getter(): + return row.cells[self.ROUTERS_TABLE_NETWORK_COLUMN] + + try: + self._wait_till_text_present_in_element(cell_getter, '-') + except exceptions.TimeoutException: + return False + return True + + def is_gateway_set(self, name): + row = self._get_row_with_router_name(name) + + def cell_getter(): + return row.cells[self.ROUTERS_TABLE_NETWORK_COLUMN] + + try: + self._wait_till_text_present_in_element(cell_getter, + self._external_network) + except exceptions.TimeoutException: + return False + return True + + def go_to_interfaces_page(self, name): + self._get_element(by.By.LINK_TEXT, name).click() + self._get_element(*self._interfaces_tab_locator).click() + return RouterInterfacesPage(self.driver, self.conf, name) + + def go_to_overview_page(self, name): + self._get_element(by.By.LINK_TEXT, name).click() + return RouterOverviewPage(self.driver, self.conf, name) diff --git a/openstack_dashboard/test/integration_tests/pages/project/network/security_groups/__init__.py b/openstack_dashboard/test/integration_tests/pages/project/network/security_groups/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/test/integration_tests/pages/project/network/security_groups/managerulespage.py b/openstack_dashboard/test/integration_tests/pages/project/network/security_groups/managerulespage.py new file mode 100644 index 0000000000..7a4580f26a --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/project/network/security_groups/managerulespage.py @@ -0,0 +1,74 @@ +# 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 openstack_dashboard.test.integration_tests.pages import basepage +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables + + +class RulesTable(tables.TableRegion): + name = 'rules' + ADD_RULE_FORM_FIELDS = ("rule_menu", "description", "direction", + "port_or_range", "port", "remote", "cidr") + + @tables.bind_table_action('add_rule') + def create_rule(self, create_button): + create_button.click() + return forms.FormRegion( + self.driver, self.conf, + field_mappings=self.ADD_RULE_FORM_FIELDS) + + @tables.bind_table_action('delete') + def delete_rules_by_table(self, delete_button): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf, None) + + @tables.bind_row_action('delete') + def delete_rule_by_row(self, delete_button, row): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf, None) + + +class ManageRulesPage(basepage.BaseNavigationPage): + + RULES_TABLE_PORT_RANGE_COLUMN = 'Port Range' + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "Manage Security Group Rules" + + def _get_row_with_port_range(self, port): + return self.rules_table.get_row( + self.RULES_TABLE_PORT_RANGE_COLUMN, port) + + @property + def rules_table(self): + return RulesTable(self.driver, self.conf) + + def create_rule(self, port): + create_rule_form = self.rules_table.create_rule() + create_rule_form.port.text = port + create_rule_form.submit() + + def delete_rule(self, port): + row = self._get_row_with_port_range(port) + modal_confirmation_form = self.rules_table.delete_rule_by_row(row) + modal_confirmation_form.submit() + + def delete_rules(self, port): + row = self._get_row_with_port_range(port) + row.mark() + modal_confirmation_form = self.rules_table.delete_rules_by_table() + modal_confirmation_form.submit() + + def is_port_present(self, port): + return bool(self._get_row_with_port_range(port)) diff --git a/openstack_dashboard/test/integration_tests/pages/project/network/securitygroupspage.py b/openstack_dashboard/test/integration_tests/pages/project/network/securitygroupspage.py new file mode 100644 index 0000000000..3041b4f0d9 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/project/network/securitygroupspage.py @@ -0,0 +1,79 @@ +# 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 openstack_dashboard.test.integration_tests.pages import basepage +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables +from openstack_dashboard.test.integration_tests.pages.project.network.\ + security_groups.managerulespage import ManageRulesPage + + +class SecurityGroupsTable(tables.TableRegion): + name = "security_groups" + CREATE_SECURITYGROUP_FORM_FIELDS = ("name", "description") + + @tables.bind_table_action('create') + def create_group(self, create_button): + create_button.click() + return forms.FormRegion( + self.driver, + self.conf, + field_mappings=self.CREATE_SECURITYGROUP_FORM_FIELDS) + + @tables.bind_table_action('delete') + def delete_group(self, delete_button): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf, None) + + @tables.bind_row_action('manage_rules') + def manage_rules(self, manage_rules_button, row): + manage_rules_button.click() + return ManageRulesPage(self.driver, self.conf) + + +class SecuritygroupsPage(basepage.BaseNavigationPage): + + SECURITYGROUPS_TABLE_NAME_COLUMN = 'Name' + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "Security Groups" + + def _get_row_with_securitygroup_name(self, name): + return self.securitygroups_table.get_row( + self.SECURITYGROUPS_TABLE_NAME_COLUMN, name) + + @property + def securitygroups_table(self): + return SecurityGroupsTable(self.driver, self.conf) + + def create_securitygroup(self, name, description=None): + create_securitygroups_form = self.securitygroups_table.create_group() + create_securitygroups_form.name.text = name + if description is not None: + create_securitygroups_form.description.text = description + create_securitygroups_form.submit() + if 'Manage Security Group Rules' in self.driver.title: + return ManageRulesPage(self.driver, self.conf) + + def delete_securitygroup(self, name): + row = self._get_row_with_securitygroup_name(name) + row.mark() + modal_confirmation_form = self.securitygroups_table.delete_group() + modal_confirmation_form.submit() + + def is_securitygroup_present(self, name): + return bool(self._get_row_with_securitygroup_name(name)) + + def go_to_manage_rules(self, name): + row = self._get_row_with_securitygroup_name(name) + return self.securitygroups_table.manage_rules(row) diff --git a/openstack_dashboard/test/integration_tests/pages/project/object_store/__init__.py b/openstack_dashboard/test/integration_tests/pages/project/object_store/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/test/integration_tests/pages/project/volumes/__init__.py b/openstack_dashboard/test/integration_tests/pages/project/volumes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/test/integration_tests/pages/project/volumes/snapshotspage.py b/openstack_dashboard/test/integration_tests/pages/project/volumes/snapshotspage.py new file mode 100644 index 0000000000..73f3d8a240 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/project/volumes/snapshotspage.py @@ -0,0 +1,133 @@ +# 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.volumes.\ + volumespage import VolumesPage +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables + + +class VolumesnapshotsTable(tables.TableRegion): + name = 'volume_snapshots' + marker_name = 'snapshot_marker' + prev_marker_name = 'prev_snapshot_marker' + + EDIT_SNAPSHOT_FORM_FIELDS = ("name", "description") + + CREATE_VOLUME_FORM_FIELDS = ( + "name", "description", "snapshot_source", "size") + + @tables.bind_table_action('delete') + def delete_volume_snapshots(self, delete_button): + """Batch Delete table action.""" + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + @tables.bind_row_action('delete') + def delete_volume_snapshot(self, delete_button, row): + """Per-entity delete row action.""" + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + @tables.bind_row_action('edit') + def edit_snapshot(self, edit_button, row): + edit_button.click() + return forms.FormRegion(self.driver, self.conf, + field_mappings=self.EDIT_SNAPSHOT_FORM_FIELDS) + + @tables.bind_row_action('create_from_snapshot') + def create_volume(self, create_volume_button, row): + create_volume_button.click() + return forms.FormRegion(self.driver, self.conf, + field_mappings=self.CREATE_VOLUME_FORM_FIELDS) + + +class SnapshotsPage(basepage.BaseNavigationPage): + SNAPSHOT_TABLE_NAME_COLUMN = 'Name' + SNAPSHOT_TABLE_STATUS_COLUMN = 'Status' + SNAPSHOT_TABLE_VOLUME_NAME_COLUMN = 'Volume Name' + _volumes_tab_locator = ( + by.By.CSS_SELECTOR, + 'a[href*="tab=volumes_and_snapshots__volumes_tab"]') + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "Volume Snapshots" + + @property + def volumesnapshots_table(self): + return VolumesnapshotsTable(self.driver, self.conf) + + def switch_to_volumes_tab(self): + self._get_element(*self._volumes_tab_locator).click() + return VolumesPage(self.driver, self.conf) + + def _get_row_with_volume_snapshot_name(self, name): + return self.volumesnapshots_table.get_row( + self.SNAPSHOT_TABLE_NAME_COLUMN, + name) + + def is_snapshot_present(self, name): + return bool(self._get_row_with_volume_snapshot_name(name)) + + def delete_volume_snapshot(self, name): + row = self._get_row_with_volume_snapshot_name(name) + confirm_form = self.volumesnapshots_table.delete_volume_snapshot(row) + confirm_form.submit() + + def delete_volume_snapshots(self, names): + for name in names: + row = self._get_row_with_volume_snapshot_name(name) + row.mark() + confirm_form = self.volumesnapshots_table.delete_volume_snapshots() + confirm_form.submit() + + def is_volume_snapshot_deleted(self, name): + return self.volumesnapshots_table.is_row_deleted( + lambda: self._get_row_with_volume_snapshot_name(name)) + + def is_volume_snapshot_available(self, name): + def cell_getter(): + row = self._get_row_with_volume_snapshot_name(name) + return row and row.cells[self.SNAPSHOT_TABLE_STATUS_COLUMN] + + return bool(self.volumesnapshots_table.wait_cell_status(cell_getter, + 'Available')) + + def get_volume_name(self, snapshot_name): + row = self._get_row_with_volume_snapshot_name(snapshot_name) + return row.cells[self.SNAPSHOT_TABLE_VOLUME_NAME_COLUMN].text + + def edit_snapshot(self, name, new_name=None, description=None): + row = self._get_row_with_volume_snapshot_name(name) + snapshot_edit_form = self.volumesnapshots_table.edit_snapshot(row) + if new_name: + snapshot_edit_form.name.text = new_name + if description: + snapshot_edit_form.description.text = description + snapshot_edit_form.submit() + + def create_volume_from_snapshot(self, snapshot_name, volume_name=None, + description=None, volume_size=None): + row = self._get_row_with_volume_snapshot_name(snapshot_name) + volume_form = self.volumesnapshots_table.create_volume(row) + if volume_name: + volume_form.name.text = volume_name + if description: + volume_form.description.text = description + if volume_size is None: + volume_size = self.conf.volume.volume_size + volume_form.size.value = volume_size + volume_form.submit() diff --git a/openstack_dashboard/test/integration_tests/pages/project/volumes/volumespage.py b/openstack_dashboard/test/integration_tests/pages/project/volumes/volumespage.py new file mode 100644 index 0000000000..870de86f8f --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/project/volumes/volumespage.py @@ -0,0 +1,275 @@ +# 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.common import exceptions +from selenium.webdriver.common.by import By + +from openstack_dashboard.test.integration_tests.pages import basepage +from openstack_dashboard.test.integration_tests.pages.project.compute \ + import instancespage +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables + +VOLUME_SOURCE_TYPE = 'Volume' +IMAGE_SOURCE_TYPE = 'Image' + + +class VolumesTable(tables.TableRegion): + name = 'volumes' + + # This form is applicable for volume creation from image only. + # Volume creation from volume requires additional 'volume_source' field + # which is available only in case at least one volume is already present. + CREATE_VOLUME_FROM_IMAGE_FORM_FIELDS = ( + "name", "description", "volume_source_type", "image_source", + "type", "size", "availability_zone") + + EDIT_VOLUME_FORM_FIELDS = ("name", "description") + + CREATE_VOLUME_SNAPSHOT_FORM_FIELDS = ("name", "description") + + EXTEND_VOLUME_FORM_FIELDS = ("new_size",) + + UPLOAD_VOLUME_FORM_FIELDS = ("image_name", "disk_format") + + @tables.bind_table_action('create') + def create_volume(self, create_button): + create_button.click() + return forms.FormRegion( + self.driver, self.conf, + field_mappings=self.CREATE_VOLUME_FROM_IMAGE_FORM_FIELDS) + + @tables.bind_table_action('delete') + def delete_volume(self, delete_button): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + @tables.bind_row_action('edit') + def edit_volume(self, edit_button, row): + edit_button.click() + return forms.FormRegion(self.driver, self.conf, + field_mappings=self.EDIT_VOLUME_FORM_FIELDS) + + @tables.bind_row_action('snapshots') + def create_snapshot(self, create_snapshot_button, row): + create_snapshot_button.click() + return forms.FormRegion( + self.driver, self.conf, + field_mappings=self.CREATE_VOLUME_SNAPSHOT_FORM_FIELDS) + + @tables.bind_row_action('extend') + def extend_volume(self, extend_button, row): + extend_button.click() + return forms.FormRegion(self.driver, self.conf, + field_mappings=self.EXTEND_VOLUME_FORM_FIELDS) + + @tables.bind_row_action('launch_volume_ng') + def launch_as_instance(self, launch_button, row): + launch_button.click() + return instancespage.LaunchInstanceForm(self.driver, self.conf) + + @tables.bind_row_action('upload_to_image') + def upload_volume_to_image(self, upload_button, row): + upload_button.click() + return forms.FormRegion(self.driver, self.conf, + field_mappings=self.UPLOAD_VOLUME_FORM_FIELDS) + + @tables.bind_row_action('attachments') + def manage_attachments(self, manage_attachments, row): + manage_attachments.click() + return VolumeAttachForm(self.driver, self.conf) + + +class VolumesPage(basepage.BaseNavigationPage): + + VOLUMES_TABLE_NAME_COLUMN = 'Name' + VOLUMES_TABLE_STATUS_COLUMN = 'Status' + VOLUMES_TABLE_TYPE_COLUMN = 'Type' + VOLUMES_TABLE_SIZE_COLUMN = 'Size' + VOLUMES_TABLE_ATTACHED_COLUMN = 'Attached To' + + def __init__(self, driver=None, conf=None): + super().__init__(driver, conf) + self._page_title = "Volumes" + + def _get_row_with_volume_name(self, name): + return self.volumes_table.get_row( + self.VOLUMES_TABLE_NAME_COLUMN, name) + + def _get_rows_with_volumes_names(self, names): + return [self.volumes_table.get_row(self.VOLUMES_TABLE_NAME_COLUMN, n) + for n in names] + + @property + def volumes_table(self): + return VolumesTable(self.driver, self.conf) + + def create_volume(self, volume_name, description=None, + volume_source_type=IMAGE_SOURCE_TYPE, + volume_size=None, + volume_source=None): + volume_form = self.volumes_table.create_volume() + volume_form.name.text = volume_name + if description is not None: + volume_form.description.text = description + volume_form.volume_source_type.text = volume_source_type + volume_source_type = self._get_source_name(volume_form, + volume_source_type, + self.conf.launch_instances, + volume_source) + volume_source_type[0].text = volume_source_type[1] + if volume_size is None: + volume_size = self.conf.volume.volume_size + volume_form.size.value = volume_size + if volume_source_type != "Volume": + volume_form.type.value = self.conf.volume.volume_type + volume_form.availability_zone.value = \ + self.conf.launch_instances.available_zone + volume_form.submit() + + def delete_volume(self, name): + row = self._get_row_with_volume_name(name) + row.mark() + confirm_delete_volumes_form = self.volumes_table.delete_volume() + confirm_delete_volumes_form.submit() + self.wait_till_spinner_disappears() + + def delete_volumes(self, volumes_names): + for volume_name in volumes_names: + self._get_row_with_volume_name(volume_name).mark() + confirm_delete_volumes_form = self.volumes_table.delete_volume() + confirm_delete_volumes_form.submit() + self.wait_till_spinner_disappears() + + def edit_volume(self, name, new_name=None, description=None): + row = self._get_row_with_volume_name(name) + volume_edit_form = self.volumes_table.edit_volume(row) + if new_name: + volume_edit_form.name.text = new_name + if description: + volume_edit_form.description.text = description + volume_edit_form.submit() + + def is_volume_present(self, name): + return bool(self._get_row_with_volume_name(name)) + + def is_volume_status(self, name, status): + def cell_getter(): + row = self._get_row_with_volume_name(name) + return row.cells[self.VOLUMES_TABLE_STATUS_COLUMN] + + try: + self._wait_till_text_present_in_element(cell_getter, status) + except exceptions.TimeoutException: + return False + return True + + def is_volume_deleted(self, name): + return self.volumes_table.is_row_deleted( + lambda: self._get_row_with_volume_name(name)) + + def are_volumes_deleted(self, volumes_names): + return self.volumes_table.are_rows_deleted( + lambda: self._get_rows_with_volumes_names(volumes_names)) + + def _get_source_name(self, volume_form, volume_source_type, conf, + volume_source): + if volume_source_type == IMAGE_SOURCE_TYPE: + return volume_form.image_source, conf.image_name + if volume_source_type == VOLUME_SOURCE_TYPE: + return volume_form.volume_id, volume_source + + def create_volume_snapshot(self, volume, snapshot, description='test'): + from openstack_dashboard.test.integration_tests.pages.project.\ + volumes.snapshotspage import SnapshotsPage + row = self._get_row_with_volume_name(volume) + snapshot_form = self.volumes_table.create_snapshot(row) + snapshot_form.name.text = snapshot + if description is not None: + snapshot_form.description.text = description + snapshot_form.submit() + return SnapshotsPage(self.driver, self.conf) + + def extend_volume(self, name, new_size): + row = self._get_row_with_volume_name(name) + extend_volume_form = self.volumes_table.extend_volume(row) + extend_volume_form.new_size.value = new_size + extend_volume_form.submit() + + def upload_volume_to_image(self, name, image_name, disk_format): + row = self._get_row_with_volume_name(name) + upload_volume_form = self.volumes_table.upload_volume_to_image(row) + upload_volume_form.image_name.text = image_name + upload_volume_form.disk_format.value = disk_format + upload_volume_form.submit() + + def get_size(self, name): + row = self._get_row_with_volume_name(name) + size = str(row.cells[self.VOLUMES_TABLE_SIZE_COLUMN].text) + return int(''.join(filter(str.isdigit, size))) + + def launch_instance(self, name, instance_name, available_zone=None): + row = self._get_row_with_volume_name(name) + instance_form = self.volumes_table.launch_as_instance(row) + if available_zone is None: + available_zone = self.conf.launch_instances.available_zone + instance_form.availability_zone.value = available_zone + instance_form.name.text = instance_name + instance_form.submit() + + def get_attach_instance(self, name): + row = self._get_row_with_volume_name(name) + attach_instance = row.cells[self.VOLUMES_TABLE_ATTACHED_COLUMN].text + return attach_instance + + def attach_volume_to_instance(self, volume, instance): + row = self._get_row_with_volume_name(volume) + attach_form = self.volumes_table.manage_attachments(row) + attach_form.attach_instance(instance) + + def is_volume_attached_to_instance(self, volume, instance): + row = self._get_row_with_volume_name(volume) + return row.cells[ + self.VOLUMES_TABLE_ATTACHED_COLUMN].text.endswith(instance) + + def detach_volume_from_instance(self, volume, instance): + row = self._get_row_with_volume_name(volume) + attachment_form = self.volumes_table.manage_attachments(row) + detach_form = attachment_form.detach(volume, instance) + detach_form.submit() + + +class VolumeAttachForm(forms.BaseFormRegion): + _attach_to_instance_selector = (By.CSS_SELECTOR, 'div > .themable-select') + _attachments_table_selector = (By.CSS_SELECTOR, 'table[id="attachments"]') + _detach_template = 'tr[data-display="Volume {0} on instance {1}"] button' + + @property + def attachments_table(self): + return self._get_element(*self._attachments_table_selector) + + @property + def instance_selector(self): + src_elem = self._get_element(*self._attach_to_instance_selector) + return forms.ThemableSelectFormFieldRegion( + self.driver, self.conf, src_elem=src_elem, + strict_options_match=False) + + def detach(self, volume, instance): + detach_button = self.attachments_table.find_element( + By.CSS_SELECTOR, self._detach_template.format(volume, instance)) + detach_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + def attach_instance(self, instance_name): + self.instance_selector.text = instance_name + self.submit() diff --git a/openstack_dashboard/test/integration_tests/pages/settings/__init__.py b/openstack_dashboard/test/integration_tests/pages/settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/test/integration_tests/pages/settings/changepasswordpage.py b/openstack_dashboard/test/integration_tests/pages/settings/changepasswordpage.py new file mode 100644 index 0000000000..7edeed5d1f --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/settings/changepasswordpage.py @@ -0,0 +1,52 @@ +# 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 time + +from selenium.webdriver.common import by + +from openstack_dashboard.test.integration_tests.pages import basepage +from openstack_dashboard.test.integration_tests.regions import forms + + +class ChangepasswordPage(basepage.BaseNavigationPage): + + _password_form_locator = (by.By.ID, 'change_password_modal') + + CHANGE_PASSWORD_FORM_FIELDS = ("current_password", "new_password", + "confirm_password") + + @property + def password_form(self): + src_elem = self._get_element(*self._password_form_locator) + return forms.FormRegion( + self.driver, self.conf, src_elem=src_elem, + field_mappings=self.CHANGE_PASSWORD_FORM_FIELDS) + + def change_password(self, current, new): + self.password_form.current_password.text = current + self.password_form.new_password.text = new + self.password_form.confirm_password.text = new + self.password_form.submit() + # NOTE(tsufiev): try to apply the same fix as Tempest did for the + # issue of Keystone Fernet tokens lacking sub-second precision + # (in which case it's possible to log in the same second that + # token was revoked due to password change), see bug 1473567 + time.sleep(1) + + def reset_to_default_password(self, current): + if self.topbar.user.text == self.conf.identity.admin_username: + return self.change_password(current, + self.conf.identity.admin_password) + else: + return self.change_password(current, + self.conf.identity.password) diff --git a/openstack_dashboard/test/integration_tests/pages/settings/usersettingspage.py b/openstack_dashboard/test/integration_tests/pages/settings/usersettingspage.py new file mode 100644 index 0000000000..e8412fb9a5 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/pages/settings/usersettingspage.py @@ -0,0 +1,83 @@ +# 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.settings import \ + changepasswordpage +from openstack_dashboard.test.integration_tests.regions import forms + + +class UsersettingsPage(basepage.BaseNavigationPage): + DEFAULT_LANGUAGE = "en" + DEFAULT_TIMEZONE = "UTC" + DEFAULT_PAGESIZE = "20" + DEFAULT_LOGLINES = "35" + DEFAULT_SETTINGS = { + "language": DEFAULT_LANGUAGE, + "timezone": DEFAULT_TIMEZONE, + "pagesize": DEFAULT_PAGESIZE, + "loglines": DEFAULT_LOGLINES + } + + SETTINGS_FORM_FIELDS = ( + "language", "timezone", "pagesize", "instance_log_length") + + _settings_form_locator = (by.By.ID, 'user_settings_modal') + _change_password_tab_locator = (by.By.CSS_SELECTOR, + 'a[href*="/settings/password/"]') + + def __init__(self, driver, conf): + super().__init__(driver, conf) + self._page_title = "User Settings" + + @property + def settings_form(self): + src_elem = self._get_element(*self._settings_form_locator) + return forms.FormRegion( + self.driver, self.conf, src_elem=src_elem, + field_mappings=self.SETTINGS_FORM_FIELDS) + + @property + def changepassword(self): + return changepasswordpage.ChangePasswordPage(self.driver, self.conf) + + @property + def change_password_tab(self): + return self._get_element(*self._change_password_tab_locator) + + def change_language(self, lang=DEFAULT_LANGUAGE): + self.settings_form.language.value = lang + self.settings_form.submit() + + def change_timezone(self, timezone=DEFAULT_TIMEZONE): + self.settings_form.timezone.value = timezone + self.settings_form.submit() + + def change_pagesize(self, size=DEFAULT_PAGESIZE): + self.settings_form.pagesize.value = size + self.settings_form.submit() + + def change_loglines(self, lines=DEFAULT_LOGLINES): + self.settings_form.instance_log_length.value = lines + self.settings_form.submit() + + def return_to_default_settings(self): + self.change_language() + self.change_timezone() + self.change_pagesize() + self.change_loglines() + + def go_to_change_password_page(self): + self.change_password_tab.click() + return changepasswordpage.ChangePasswordPage(self.driver, self.conf) diff --git a/openstack_dashboard/test/integration_tests/regions/__init__.py b/openstack_dashboard/test/integration_tests/regions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/test/integration_tests/regions/bars.py b/openstack_dashboard/test/integration_tests/regions/bars.py new file mode 100644 index 0000000000..201a75cd5f --- /dev/null +++ b/openstack_dashboard/test/integration_tests/regions/bars.py @@ -0,0 +1,61 @@ +# 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.regions import baseregion +from openstack_dashboard.test.integration_tests.regions import menus + + +class TopBarRegion(baseregion.BaseRegion): + _user_dropdown_menu_locator = (by.By.CSS_SELECTOR, + '.nav.navbar-nav.navbar-right') + _openstack_brand_locator = (by.By.CSS_SELECTOR, 'a[href*="/home/"]') + + _user_dropdown_project_locator = ( + by.By.CSS_SELECTOR, '.navbar-collapse > ul.navbar-nav:first-child') + _header_locator = (by.By.CSS_SELECTOR, 'nav.navbar-fixed-top') + + MATERIAL_THEME_CLASS = 'material-header' + + @property + def user(self): + return self._get_element(*self._user_dropdown_menu_locator) + + @property + def brand(self): + return self._get_element(*self._openstack_brand_locator) + + @property + def header(self): + return self._get_element(*self._header_locator) + + @property + def is_material_theme_enabled(self): + classes = self.header.get_attribute('class').strip().split() + return self.MATERIAL_THEME_CLASS in classes + + @property + def user_dropdown_menu(self): + src_elem = self._get_element(*self._user_dropdown_menu_locator) + return menus.UserDropDownMenuRegion(self.driver, + self.conf, src_elem) + + @property + def is_logged_in(self): + return self._is_element_visible(*self._user_dropdown_menu_locator) + + @property + def user_dropdown_project(self): + src_elem = self._get_element(*self._user_dropdown_project_locator) + return menus.ProjectDropDownRegion(self.driver, + self.conf, src_elem) diff --git a/openstack_dashboard/test/integration_tests/regions/baseregion.py b/openstack_dashboard/test/integration_tests/regions/baseregion.py new file mode 100644 index 0000000000..29dc4d7eb7 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/regions/baseregion.py @@ -0,0 +1,64 @@ +# 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 openstack_dashboard.test.integration_tests import basewebobject + + +class BaseRegion(basewebobject.BaseWebObject): + """Base class for region module + + * there is necessity to override some basic methods for obtaining elements + as in content of regions it is required to do relative searches + + * self.driver cannot be easily replaced with self.src_elem because that + would result in functionality loss, self.driver is WebDriver and + src_elem is WebElement its usage is different. + + * this does not mean that self.src_elem cannot be self.driver + """ + + _default_src_locator = None + + # private methods + def __init__(self, driver, conf, src_elem=None): + super().__init__(driver, conf) + if self._default_src_locator: + root = src_elem or driver + src_elem = root.find_element(*self._default_src_locator) + + self.src_elem = src_elem or driver + + # variable for storing names of dynamic properties and + # associated 'getters' - meaning method that are supplying + # regions or web elements + self._dynamic_properties = {} + + def __getattr__(self, name): + # It is not possible to create property bounded just to object + # and not class at runtime, therefore it is necessary to + # override __getattr__ and make fake 'properties' by storing them in + # the protected attribute _dynamic_attributes and returning result + # of the method associated with the specified attribute. + + # This way the feeling of having regions accessed as 'properties' + # is created, which is one of the requirement of page object pattern. + + try: + return self._dynamic_properties[name] + except KeyError: + msg = "'{0}' object has no attribute '{1}'" + raise AttributeError(msg.format(type(self).__name__, name)) + + def _get_element(self, *locator): + return self.src_elem.find_element(*locator) + + def _get_elements(self, *locator): + return self.src_elem.find_elements(*locator) diff --git a/openstack_dashboard/test/integration_tests/regions/exceptions.py b/openstack_dashboard/test/integration_tests/regions/exceptions.py new file mode 100644 index 0000000000..fa4c26b872 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/regions/exceptions.py @@ -0,0 +1,22 @@ +# 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. + + +class BaseRegionException(Exception): + """Base exception class for region module.""" + pass + + +class UnknownFormFieldTypeException(BaseRegionException): + + def __str__(self): + return "No FormField class matched the scope of web content." diff --git a/openstack_dashboard/test/integration_tests/regions/forms.py b/openstack_dashboard/test/integration_tests/regions/forms.py new file mode 100644 index 0000000000..2df2c50f88 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/regions/forms.py @@ -0,0 +1,652 @@ +# 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 ButtonGroupFormFieldRegion(BaseFormFieldRegion): + """Select button group.""" + + _element_locator_str_suffix = 'div.btn-group' + _button_label_locator = (by.By.CSS_SELECTOR, 'label.btn') + + @property + def options(self): + options = self._get_elements(*self._button_label_locator) + results = {opt.text: opt for opt in options} + return results + + def pick(self, option): + return self.options[option].click() + + +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 FormRegionNG(FormRegion): + """Angular-based form.""" + + _fields_locator = (by.By.CSS_SELECTOR, 'div.content') + _submit_locator = (by.By.CSS_SELECTOR, '*.btn.btn-primary.finish') + + +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)) diff --git a/openstack_dashboard/test/integration_tests/regions/menus.py b/openstack_dashboard/test/integration_tests/regions/menus.py new file mode 100644 index 0000000000..44ca32fe52 --- /dev/null +++ b/openstack_dashboard/test/integration_tests/regions/menus.py @@ -0,0 +1,468 @@ +# 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 time + +from selenium.common import exceptions +from selenium.webdriver.common import by + +from openstack_dashboard.test.integration_tests.regions import baseregion + + +class NavigationAccordionRegion(baseregion.BaseRegion): + """Navigation menu located in the left.""" + _project_security_groups_locator = ( + by.By.CSS_SELECTOR, 'a[href*="/project/security_groups/"]') + _project_key_pairs_locator = ( + by.By.CSS_SELECTOR, 'a[href*="/project/key_pairs/"]') + _settings_change_password_locator = ( + by.By.CSS_SELECTOR, 'a[href*="/settings/password//"]') + _project_bar_locator = (by.By.XPATH, + ".//*[@id='main_content']//div[contains(text()," + "'Project')]") + + @property + def project_bar(self): + return self._get_element(*self._project_bar_locator) + + _first_level_item_selected_locator = ( + by.By.CSS_SELECTOR, '.panel.openstack-dashboard > a:not(.collapsed)') + _second_level_item_selected_locator = ( + by.By.CSS_SELECTOR, '.panel.openstack-panel-group > a:not(.collapsed)') + + _first_level_item_xpath_template = ( + "//li[contains(concat('', @class, ''), 'panel openstack-dashboard') " + "and contains(., '%s')]/a") + _second_level_item_xpath_template = ( + "//ul[contains(@class, 'in')]//li[contains(@class, " + "'panel openstack-panel-group') and contains(., '%s')]/a") + _third_level_item_xpath_template = ( + "//ul[contains(@class, 'in')]//a[contains(concat('', @class, '')," + "'list-group-item openstack-panel') and contains(., '%s')]") + + _parent_item_locator = (by.By.XPATH, '..') + _menu_list_locator = (by.By.CSS_SELECTOR, 'a') + _expanded_menu_class = "" + _transitioning_menu_class = 'collapsing' + _form_body_locator = (by.By.CSS_SELECTOR, 'body') + + 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 get_form_body(self): + return self._get_element(*self._form_body_locator) + + @property + def security_groups(self): + return self._get_element(*self._project_security_groups_locator) + + @property + def key_pairs(self): + return self._get_element(*self._project_key_pairs_locator) + + @property + def change_password(self): + return self._get_element(*self._settings_change_password_locator) + + def _wait_until_transition_ends(self, item, to_be_expanded=False): + def predicate(d): + classes = item.get_attribute('class') + if to_be_expanded: + status = self._expanded_menu_class == classes + else: + status = self._expanded_menu_class is not classes + return status and self._transitioning_menu_class not in classes + self._wait_until(predicate) + + def _wait_until_modal_dialog_close(self): + def predicate(d): + classes = self.get_form_body.get_attribute('class') + return classes and "modal-open" not in classes + self._wait_until(predicate) + + def _click_menu_item(self, text, loc_craft_func, get_selected_func=None, + src_elem=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 wait for the transition + to complete first. + + Third-level menus are handled differently from others. First, they do + not need to collapse other 3rd-level menu item prior to clicking them + (because 3rd-level menus are atomic). Second, clicking them doesn't + initiate an animated transition, hence no need to wait until it + finishes. Also an ambiguity is possible when matching third-level menu + items, because different dashboards have panels with the same name (for + example Volumes/Images/Instances both at Project and Admin dashboards). + To avoid this ambiguity, an argument `src_elem` is used. Whenever + dashboard or a panel group is clicked, its wrapper is returned to be + used as `src_elem` in a subsequent call for clicking third-level item. + This way the set of panel labels being matched is restricted to the + descendants of that particular dashboard or panel group. + """ + is_already_within_required_item = False + selected_item = None + self._wait_until_modal_dialog_close() + if get_selected_func is not None: + selected_item = get_selected_func() + if selected_item: + if text != selected_item.text and selected_item.text: + # In case different item was chosen previously, collapse + # it. Otherwise selenium will complain with + # MoveTargetOutOfBoundsException + selected_item.click() + time.sleep(1) + self._wait_until_transition_ends( + self._get_menu_list_next_to_menu_title(selected_item)) + else: + is_already_within_required_item = True + + # In case menu item is collapsed, then below code detects the same + # and disable is_already_within_required_item flag. + # For instance, in case of tacker-horizon, selenium report an + # exception ElementNotInteractableException while opening pages + # under 'NFV Orchestration' panel group. This error occurs when + # element is not clickable or it is not visible yet. + # The panel group 'NFV Orchestration' is never clicked/open hence + # requested pages are not visible/clickable. + item = self._get_item(text, loc_craft_func, src_elem) + if "collapsed" == item.get_attribute( + 'class') and is_already_within_required_item is True: + is_already_within_required_item = False + + if not is_already_within_required_item: + item.click() + if get_selected_func is not None: + self._wait_until_transition_ends( + self._get_menu_list_next_to_menu_title(item), + to_be_expanded=True) + return item + return selected_item + + def _get_item(self, text, loc_craft_func, src_elem=None): + item_locator = loc_craft_func(text) + src_elem = src_elem or self.src_elem + return src_elem.find_element(*item_locator) + + def _get_menu_list_next_to_menu_title(self, title_item): + parent_item = title_item.find_element(*self._parent_item_locator) + return parent_item.find_element(*self._menu_list_locator) + + def click_on_menu_items(self, first_level=None, + second_level=None, + third_level=None): + src_elem = None + if first_level: + src_elem = self._click_menu_item( + first_level, + self._get_first_level_item_locator, + self.get_first_level_selected_item) + if second_level: + src_elem = self._click_menu_item( + second_level, + self._get_second_level_item_locator, + self.get_second_level_selected_item) + + if third_level: + # NOTE(tsufiev): possible dashboard/panel group label passed as + # `src_elem` is a sibling of