From 36536272ff6e1f191cf150892af814a6d41ba0ae Mon Sep 17 00:00:00 2001 From: Radomir Dopieralski Date: Mon, 31 Jul 2023 13:58:58 +0200 Subject: [PATCH] Add pytest-based selenium tests This is a patch to start work on re-writing the integration tests using pytest syntax and several improvements, as proposed on the upstream meeting, and summarized at https://etherpad.opendev.org/p/horizon-pytest The new tests are to eventually replace the existing integration tests. At the moment they don't run automatically, you have to explicitly run them using tox or pytest. When the new tests are complete, we will switch to them on the gate. Change-Id: Iea38e4f9771ff3cae7ae8675863e9c488f3f6d8a --- .../test/integration_tests/config.py | 3 + .../test/integration_tests/horizon.conf | 3 + openstack_dashboard/test/selenium/__init__.py | 0 openstack_dashboard/test/selenium/conftest.py | 233 +++++++++++++++ .../test/selenium/integration/conftest.py | 59 ++++ .../selenium/integration/test_instances.py | 277 ++++++++++++++++++ .../test_login.py} | 20 +- .../test/selenium/integration/test_volumes.py | 146 +++++++++ .../test/selenium/ui/conftest.py | 90 ++++++ .../test/selenium/ui/test_settings.py | 43 +++ openstack_dashboard/test/selenium/widgets.py | 49 ++++ tools/selenium_tests.sh | 3 +- tools/unit_tests.sh | 7 +- tox.ini | 24 ++ 14 files changed, 945 insertions(+), 12 deletions(-) delete mode 100644 openstack_dashboard/test/selenium/__init__.py create mode 100644 openstack_dashboard/test/selenium/conftest.py create mode 100644 openstack_dashboard/test/selenium/integration/conftest.py create mode 100644 openstack_dashboard/test/selenium/integration/test_instances.py rename openstack_dashboard/test/selenium/{selenium_tests.py => integration/test_login.py} (58%) create mode 100644 openstack_dashboard/test/selenium/integration/test_volumes.py create mode 100644 openstack_dashboard/test/selenium/ui/conftest.py create mode 100644 openstack_dashboard/test/selenium/ui/test_settings.py create mode 100644 openstack_dashboard/test/selenium/widgets.py diff --git a/openstack_dashboard/test/integration_tests/config.py b/openstack_dashboard/test/integration_tests/config.py index 7802086fa4..ebd740e2df 100644 --- a/openstack_dashboard/test/integration_tests/config.py +++ b/openstack_dashboard/test/integration_tests/config.py @@ -19,6 +19,9 @@ DashboardGroup = [ cfg.StrOpt('dashboard_url', default='http://localhost/dashboard/', help='Where the dashboard can be found'), + cfg.StrOpt('auth_url', + default='http://localhost/identity/v3', + help='Where the keystone can be found'), cfg.StrOpt('help_url', default='https://docs.openstack.org/', help='Dashboard help page url'), diff --git a/openstack_dashboard/test/integration_tests/horizon.conf b/openstack_dashboard/test/integration_tests/horizon.conf index 15f982a40b..ad57ee37a5 100644 --- a/openstack_dashboard/test/integration_tests/horizon.conf +++ b/openstack_dashboard/test/integration_tests/horizon.conf @@ -6,6 +6,9 @@ # Where the dashboard can be found (string value) dashboard_url=http://localhost/dashboard/ +# Where the keystone endpoint is +auth_url=http://localhost/identity/v3 + # Dashboard help page url (string value) help_url=https://docs.openstack.org/ diff --git a/openstack_dashboard/test/selenium/__init__.py b/openstack_dashboard/test/selenium/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openstack_dashboard/test/selenium/conftest.py b/openstack_dashboard/test/selenium/conftest.py new file mode 100644 index 0000000000..b390118f1d --- /dev/null +++ b/openstack_dashboard/test/selenium/conftest.py @@ -0,0 +1,233 @@ +# 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 os +import signal +import socket +import subprocess +from threading import Thread +import time + +import pytest +import xvfbwrapper + +from horizon.test import webdriver +from openstack_dashboard.test.integration_tests import config as horizon_config + + +STASH_FAILED = pytest.StashKey[bool]() + + +class Session: + def __init__(self, driver, config): + self.current_user = None + self.current_project = None + self.driver = driver + self.credentials = { + 'user': ( + config.identity.username, + config.identity.password, + config.identity.home_project, + ), + 'admin': ( + config.identity.admin_username, + config.identity.admin_password, + config.identity.admin_home_project, + ), + } + self.logout_url = '/'.join(( + config.dashboard.dashboard_url, + 'auth', + 'logout', + )) + + def login(self, user, project=None): + if project is None: + project = self.credentials[user][2] + if self.current_user != user: + username, password, home_project = self.credentials[user] + self.driver.get(self.logout_url) + user_field = self.driver.find_element_by_id('id_username') + user_field.send_keys(username) + pass_field = self.driver.find_element_by_id('id_password') + pass_field.send_keys(password) + button = self.driver.find_element_by_css_selector( + 'div.panel-footer button.btn') + button.click() + self.current_user = user + self.current_project = self.driver.find_element_by_xpath( + '//*[@class="context-project"]').text + if self.current_project != project: + dropdown_project = self.driver.find_element_by_xpath( + '//*[@class="context-project"]//ancestor::ul') + dropdown_project.click() + selection = dropdown_project.find_element_by_xpath( + f'//span[contains(text(),"{project}")]') + selection.click() + self.current_project = self.driver.find_element_by_xpath( + '//*[@class="context-project"]').text + + +@pytest.fixture(scope='session') +def login(driver, config): + session = Session(driver, config) + return session.login + + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport(item, call): + """A hook to save the failure state of a test.""" + # execute all other hooks to obtain the report object + outcome = yield + rep = outcome.get_result() + + item.stash[STASH_FAILED] = item.stash.get(STASH_FAILED, False) or rep.failed + + +@pytest.fixture(scope='function', autouse=True) +def save_screenshot(request, report_dir, driver): + yield None + if not request.node.stash.get(STASH_FAILED, False): + return + screen_path = os.path.join(report_dir, 'screenshot.png') + driver.get_screenshot_as_file(screen_path) + + +@pytest.fixture(scope='function', autouse=True) +def save_page_source(request, report_dir, driver): + yield None + if not request.node.stash.get(STASH_FAILED, False): + return + source_path = os.path.join(report_dir, 'page.html') + html_elem = driver.find_element_by_tag_name("html") + page_source = html_elem.get_property("innerHTML") + with open(source_path, 'w') as f: + f.write(page_source) + + +@pytest.fixture(scope='function', autouse=True) +def record_video(request, report_dir, xdisplay): + if not os.environ.get('FFMPEG_INSTALLED', False): + yield None + return + filepath = os.path.join(report_dir, 'video.mp4') + frame_rate = 15 + display, width, height = xdisplay + command = [ + 'ffmpeg', + '-video_size', '{}x{}'.format(width, height), + '-framerate', str(frame_rate), + '-f', 'x11grab', + '-i', display, + filepath, + ] + fnull = open(os.devnull, 'w') + popen = subprocess.Popen(command, stdout=fnull, stderr=fnull) + yield None + popen.send_signal(signal.SIGINT) + + def terminate_process(): + limit = time.time() + 10 + while time.time() < limit: + time.sleep(0.1) + if popen.poll() is not None: + return + os.kill(popen.pid, signal.SIGTERM) + + thread = Thread(target=terminate_process) + thread.start() + popen.communicate() + thread.join() + if not request.node.stash.get(STASH_FAILED, False): + os.remove(filepath) + + +@pytest.fixture(scope='session') +def xdisplay(): + IS_SELENIUM_HEADLESS = os.environ.get('SELENIUM_HEADLESS', False) + if IS_SELENIUM_HEADLESS: + width, height = 1920, 1080 + 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(vdisplay, 'extra_xvfb_args'): + # xvfbwrapper 0.2.8 or newer + vdisplay.extra_xvfb_args.extend(args) + else: + vdisplay.xvfb_cmd.extend(args) + vdisplay.start() + display = vdisplay.new_display + else: + width, height = subprocess.check_output( + 'xdpyinfo | grep "dimensions:"', shell=True + ).decode().split(':', 1)[1].split()[0].strip().split('x') + vdisplay = None + display = subprocess.check_output( + 'xdpyinfo | grep "name of display:"', shell=True + ).decode().split(':', 1)[1].strip() + yield display, width, height + if vdisplay: + vdisplay.stop() + + +@pytest.fixture(scope='session') +def config(): + return horizon_config.get_config() + + +@pytest.fixture(scope='function') +def report_dir(request, config): + root_path = os.path.dirname(os.path.abspath(horizon_config.__file__)) + test_name = request.node.nodeid.rsplit('/', 1)[1] + report_dir = os.path.join( + root_path, config.selenium.screenshots_directory, test_name) + if not os.path.isdir(report_dir): + os.makedirs(report_dir) + yield report_dir + try: + os.rmdir(report_dir) # delete if empty + except OSError: + pass + + +@pytest.fixture(scope='session') +def driver(config, xdisplay): + # Start a virtual display server for running the tests headless. + IS_SELENIUM_HEADLESS = os.environ.get('SELENIUM_HEADLESS', False) + # 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'} + driver = webdriver.WebDriver( + desired_capabilities=desired_capabilities + ) + if config.selenium.maximize_browser: + driver.maximize_window() + if IS_SELENIUM_HEADLESS: # force full screen in xvfb + display, width, height = xdisplay + driver.set_window_size(width, height) + + driver.implicitly_wait(config.selenium.implicit_wait) + driver.set_page_load_timeout(config.selenium.page_timeout) + yield driver + driver.quit() diff --git a/openstack_dashboard/test/selenium/integration/conftest.py b/openstack_dashboard/test/selenium/integration/conftest.py new file mode 100644 index 0000000000..3a86b5259b --- /dev/null +++ b/openstack_dashboard/test/selenium/integration/conftest.py @@ -0,0 +1,59 @@ +# 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 openstack as openstack_sdk +import pytest + + +def create_conn(username, password, project, domain, auth_url): + if not domain: + domain = 'default' + conn = openstack_sdk.connection.Connection( + auth={ + "auth_url": auth_url, + "user_domain_id": domain, + "project_domain_id": domain, + "project_name": project, + "username": username, + "password": password, + }, + compute_api_version='2', + verify=False, + ) + conn.authorize() + return conn + + +@pytest.fixture(scope='session') +def openstack_admin(config): + conn = create_conn( + config.identity.admin_username, + config.identity.admin_password, + config.identity.admin_home_project, + config.identity.domain, + config.dashboard.auth_url, + ) + yield conn + conn.close() + + +@pytest.fixture(scope='session') +def openstack_demo(config): + conn = create_conn( + config.identity.username, + config.identity.password, + config.identity.home_project, + config.identity.domain, + config.dashboard.auth_url, + ) + yield conn + conn.close() diff --git a/openstack_dashboard/test/selenium/integration/test_instances.py b/openstack_dashboard/test/selenium/integration/test_instances.py new file mode 100644 index 0000000000..7a9ed6bf0b --- /dev/null +++ b/openstack_dashboard/test/selenium/integration/test_instances.py @@ -0,0 +1,277 @@ +# 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 oslo_utils import uuidutils +import pytest +from selenium.common import exceptions + +from openstack_dashboard.test.selenium import widgets + + +@pytest.fixture +def instance_name(): + return 'xhorizon_instance_%s' % uuidutils.generate_uuid(dashed=False) + + +@pytest.fixture +def new_instance_demo(instance_name, openstack_demo, config): + + instance = openstack_demo.create_server( + instance_name, + image=config.image.images_list[0], + flavor=config.launch_instances.flavor, + availability_zone=config.launch_instances.available_zone, + network=config.network.external_network, + wait=True, + ) + yield instance + openstack_demo.delete_server(instance_name) + + +@pytest.fixture +def new_instance_admin(instance_name, openstack_admin, config): + + instance = openstack_admin.create_server( + instance_name, + image=config.image.images_list, + flavor=config.launch_instances.flavor, + availability_zone=config.launch_instances.available_zone, + network=config.network.external_network, + wait=True, + ) + yield instance + openstack_admin.delete_server(instance_name) + + +@pytest.fixture +def clear_instance_demo(instance_name, openstack_demo): + yield None + openstack_demo.delete_server( + instance_name, + wait=True, + ) + + +@pytest.fixture +def clear_instance_admin(instance_name, openstack_admin): + yield None + openstack_admin.delete_server( + instance_name, + wait=True, + ) + + +def select_from_transfer_table(element, label): + """Choose row from available Images, Flavors, Networks, etc. + + in launch tab for example: m1.tiny for Flavor, cirros for image, etc. + """ + + try: + element.find_element_by_xpath( + f"//*[text()='{label}']//ancestor::tr/td//*" + f"[@class='btn btn-default fa fa-arrow-up']").click() + except exceptions.NoSuchElementException: + try: + element.find_element_by_xpath( + f"//*[text()='{label}']//ancestor::tr/td//*" + f"[@class='btn btn-default fa fa-arrow-down']") + except exceptions.NoSuchElementException: + raise + + +def create_new_volume_during_create_instance(driver, required_state): + create_new_volume_btn = widgets.find_already_visible_element_by_xpath( + f"//*[@id='vol-create'][text()='{required_state}']", driver + ) + create_new_volume_btn.click() + + +def delete_volume_on_instance_delete(driver, required_state): + delete_volume_btn = widgets.find_already_visible_element_by_xpath( + f"//label[contains(@ng-model, 'vol_delete_on_instance_delete')]" + f"[text()='{required_state}']", driver) + delete_volume_btn.click() + + +def test_create_instance_demo(login, driver, instance_name, + clear_instance_demo, config): + image = config.launch_instances.image_name + network = config.network.external_network + flavor = config.launch_instances.flavor + + login('user') + url = '/'.join(( + config.dashboard.dashboard_url, + 'project', + 'instances', + )) + driver.get(url) + driver.find_element_by_link_text("Launch Instance").click() + wizard = driver.find_element_by_css_selector("wizard") + navigation = wizard.find_element_by_css_selector("div.wizard-nav") + widgets.find_already_visible_element_by_xpath( + "//*[@id='name']", wizard).send_keys(instance_name) + navigation.find_element_by_link_text("Networks").click() + network_table = wizard.find_element_by_css_selector( + "ng-include[ng-form=launchInstanceNetworkForm]" + ) + select_from_transfer_table(network_table, network) + navigation.find_element_by_link_text("Flavor").click() + flavor_table = wizard.find_element_by_css_selector( + "ng-include[ng-form=launchInstanceFlavorForm]" + ) + select_from_transfer_table(flavor_table, flavor) + navigation.find_element_by_link_text("Source").click() + source_table = wizard.find_element_by_css_selector( + "ng-include[ng-form=launchInstanceSourceForm]" + ) +# create_new_volume_during_create_instance(source_table, "No") + delete_volume_on_instance_delete(source_table, "Yes") + select_from_transfer_table(source_table, image) + wizard.find_element_by_css_selector( + "button.btn-primary.finish").click() + widgets.find_already_visible_element_by_xpath( + f"//*[contains(text(),'{instance_name}')]//ancestor::tr/td" + f"[contains(text(),'Active')]", driver) + assert True + + +def test_create_instance_from_volume_demo(login, driver, instance_name, + volume_name, new_volume_demo, + clear_instance_demo, config): + network = config.network.external_network + flavor = config.launch_instances.flavor + + login('user') + url = '/'.join(( + config.dashboard.dashboard_url, + 'project', + 'instances', + )) + driver.get(url) + driver.find_element_by_link_text("Launch Instance").click() + wizard = driver.find_element_by_css_selector("wizard") + navigation = wizard.find_element_by_css_selector("div.wizard-nav") + widgets.find_already_visible_element_by_xpath( + "//*[@id='name']", wizard).send_keys(instance_name) + navigation.find_element_by_link_text("Networks").click() + network_table = wizard.find_element_by_css_selector( + "ng-include[ng-form=launchInstanceNetworkForm]" + ) + select_from_transfer_table(network_table, network) + navigation.find_element_by_link_text("Flavor").click() + flavor_table = wizard.find_element_by_css_selector( + "ng-include[ng-form=launchInstanceFlavorForm]" + ) + select_from_transfer_table(flavor_table, flavor) + navigation.find_element_by_link_text("Source").click() + source_table = wizard.find_element_by_css_selector( + "ng-include[ng-form=launchInstanceSourceForm]" + ) + select_boot_sources_type_tab = source_table.find_element_by_xpath( + "//*[@id='boot-source-type']") + select_boot_sources_type_tab.click() + select_boot_sources_type_tab.find_element_by_xpath( + "//option[@value='volume']").click() + delete_volume_on_instance_delete(source_table, "No") + select_from_transfer_table(source_table, volume_name) + wizard.find_element_by_css_selector("button.btn-primary.finish").click() + widgets.find_already_visible_element_by_xpath( + f"//*[contains(text(),'{instance_name}')]//ancestor::tr/td\ + [contains(text(),'Active')]", driver) + assert True + + +def test_delete_instance_demo(login, driver, instance_name, + new_instance_demo, config): + login('user') + url = '/'.join(( + config.dashboard.dashboard_url, + 'project', + 'instances', + )) + driver.get(url) + rows = driver.find_elements_by_css_selector( + f"table#instances tr[data-display='{instance_name}']" + ) + assert len(rows) == 1 + actions_column = rows[0].find_element_by_css_selector("td.actions_column") + widgets.select_from_dropdown(actions_column, " Delete Instance") + widgets.confirm_modal(driver) + messages = widgets.get_and_dismiss_messages(driver) + assert f"Info: Scheduled deletion of Instance: {instance_name}" in messages + + +# Admin tests + + +def test_create_instance_admin(login, driver, instance_name, + clear_instance_admin, config): + image = config.launch_instances.image_name + network = config.network.external_network + flavor = config.launch_instances.flavor + + login('admin') + url = '/'.join(( + config.dashboard.dashboard_url, + 'project', + 'instances', + )) + driver.get(url) + driver.find_element_by_link_text("Launch Instance").click() + wizard = driver.find_element_by_css_selector("wizard") + navigation = wizard.find_element_by_css_selector("div.wizard-nav") + widgets.find_already_visible_element_by_xpath( + "//*[@id='name']", wizard).send_keys(instance_name) + navigation.find_element_by_link_text("Networks").click() + network_table = wizard.find_element_by_css_selector( + "ng-include[ng-form=launchInstanceNetworkForm]" + ) + select_from_transfer_table(network_table, network) + navigation.find_element_by_link_text("Flavor").click() + flavor_table = wizard.find_element_by_css_selector( + "ng-include[ng-form=launchInstanceFlavorForm]" + ) + select_from_transfer_table(flavor_table, flavor) + navigation.find_element_by_link_text("Source").click() + source_table = wizard.find_element_by_css_selector( + "ng-include[ng-form=launchInstanceSourceForm]" + ) +# create_new_volume_during_create_instance(source_table, "No") + delete_volume_on_instance_delete(source_table, "Yes") + select_from_transfer_table(source_table, image) + wizard.find_element_by_css_selector( + "button.btn-primary.finish").click() + widgets.find_already_visible_element_by_xpath( + f"//*[contains(text(),'{instance_name}')]//ancestor::tr/td\ + [contains(text(),'Active')]", driver) + assert True + + +def test_delete_instance_admin(login, driver, instance_name, + new_instance_admin, config): + login('admin') + url = '/'.join(( + config.dashboard.dashboard_url, + 'project', + 'instances', + )) + driver.get(url) + rows = driver.find_elements_by_css_selector( + f"table#instances tr[data-display='{instance_name}']" + ) + assert len(rows) == 1 + actions_column = rows[0].find_element_by_css_selector("td.actions_column") + widgets.select_from_dropdown(actions_column, " Delete Instance") + widgets.confirm_modal(driver) + messages = widgets.get_and_dismiss_messages(driver) + assert f"Info: Scheduled deletion of Instance: {instance_name}" in messages diff --git a/openstack_dashboard/test/selenium/selenium_tests.py b/openstack_dashboard/test/selenium/integration/test_login.py similarity index 58% rename from openstack_dashboard/test/selenium/selenium_tests.py rename to openstack_dashboard/test/selenium/integration/test_login.py index c9d787a1f0..d27fa680e3 100644 --- a/openstack_dashboard/test/selenium/selenium_tests.py +++ b/openstack_dashboard/test/selenium/integration/test_login.py @@ -1,5 +1,3 @@ -# Copyright 2012 Nebula, Inc. -# # 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 @@ -12,12 +10,16 @@ # License for the specific language governing permissions and limitations # under the License. -from horizon.test import helpers as test + +def test_user_login(login, driver): + login('user') + user_dropdown_menu = driver.find_element_by_css_selector( + '.nav.navbar-nav.navbar-right') + assert user_dropdown_menu.is_displayed() -class BrowserTests(test.SeleniumTestCase): - def test_splash(self): - self.selenium.get(self.live_server_url) - button = self.selenium.find_element_by_id("loginBtn") - # Ensure button has something; must be language independent. - self.assertGreater(len(button.text), 0) +def test_admin_login(login, driver): + login('admin') + user_dropdown_menu = driver.find_element_by_css_selector( + '.nav.navbar-nav.navbar-right') + assert user_dropdown_menu.is_displayed() diff --git a/openstack_dashboard/test/selenium/integration/test_volumes.py b/openstack_dashboard/test/selenium/integration/test_volumes.py new file mode 100644 index 0000000000..b2f7c81b72 --- /dev/null +++ b/openstack_dashboard/test/selenium/integration/test_volumes.py @@ -0,0 +1,146 @@ +# 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 oslo_utils import uuidutils +import pytest + +from openstack_dashboard.test.selenium import widgets + + +@pytest.fixture +def volume_name(): + return 'horizon_volume_%s' % uuidutils.generate_uuid(dashed=False) + + +@pytest.fixture +def new_volume_demo(volume_name, openstack_demo, config): + + volume = openstack_demo.create_volume( + name=volume_name, + image=config.launch_instances.image_name, + size=1, + wait=True, + ) + yield volume + openstack_demo.delete_volume(volume_name) + + +@pytest.fixture +def new_volume_admin(volume_name, openstack_admin, config): + + volume = openstack_admin.create_volume( + name=volume_name, + image=config.launch_instances.image_name, + size=1, + wait=True, + ) + yield volume + openstack_admin.delete_volume(volume_name) + + +@pytest.fixture +def clear_volume_demo(volume_name, openstack_demo): + yield None + openstack_demo. delete_volume( + volume_name, + wait=True, + ) + + +@pytest.fixture +def clear_volume_admin(volume_name, openstack_admin): + yield None + openstack_admin. delete_volume( + volume_name, + wait=True, + ) + + +def select_from_dropdown_volume_tab(driver, dropdown_id, label): + volume_dropdown = driver.find_element_by_xpath( + f"//*[@for='{dropdown_id}']/following-sibling::div") + volume_dropdown.click() + volume_dropdown_tab = volume_dropdown.find_element_by_css_selector( + "ul.dropdown-menu") + volume_dropdown_tab.find_element_by_xpath(f"//*[text()='{label}']").click() + + +def test_create_empty_volume_demo(login, driver, volume_name, + clear_volume_demo, config): + + login('user') + url = '/'.join(( + config.dashboard.dashboard_url, + 'project', + 'volumes', + )) + driver.get(url) + driver.find_element_by_link_text("Create Volume").click() + volume_form = driver.find_element_by_css_selector(".modal-dialog form") + volume_form.find_element_by_xpath( + "//*[@id='id_name']").send_keys(volume_name) + volume_form.find_element_by_xpath( + "//*[@class='btn btn-primary'][@value='Create Volume']").click() + messages = widgets.get_and_dismiss_messages(driver) + assert f'Info: Creating volume "{volume_name}"' in messages + widgets.find_already_visible_element_by_xpath( + f"//*[contains(text(),'{volume_name}')]//ancestor::tr/td" + f"[contains(text(),'Available')]", driver) + assert True + + +def test_create_volume_from_image_demo(login, driver, volume_name, + clear_volume_demo, config): + image_source_name = "cirros-0.6.2-x86_64-disk (20.4 MB)" + + login('user') + url = '/'.join(( + config.dashboard.dashboard_url, + 'project', + 'volumes', + )) + driver.get(url) + driver.find_element_by_link_text("Create Volume").click() + volume_form = driver.find_element_by_css_selector(".modal-dialog form") + volume_form.find_element_by_xpath( + "//*[@id='id_name']").send_keys(volume_name) + select_from_dropdown_volume_tab( + volume_form, 'id_volume_source_type', 'Image') + select_from_dropdown_volume_tab( + volume_form, 'id_image_source', image_source_name) + volume_form.find_element_by_xpath( + "//*[@class='btn btn-primary'][@value='Create Volume']").click() + messages = widgets.get_and_dismiss_messages(driver) + assert f'Info: Creating volume "{volume_name}"' in messages + widgets.find_already_visible_element_by_xpath( + f"//*[contains(text(),'{volume_name}')]//ancestor::tr/td" + f"[contains(text(),'Available')]", driver) + assert True + + +def test_delete_volume_demo(login, driver, volume_name, + new_volume_demo, config): + login('user') + url = '/'.join(( + config.dashboard.dashboard_url, + 'project', + 'volumes', + )) + driver.get(url) + rows = driver.find_elements_by_css_selector( + f"table#volumes tr[data-display='{volume_name}']" + ) + assert len(rows) == 1 + actions_column = rows[0].find_element_by_css_selector("td.actions_column") + widgets.select_from_dropdown(actions_column, " Delete Volume") + widgets.confirm_modal(driver) + messages = widgets.get_and_dismiss_messages(driver) + assert f"Info: Scheduled deletion of Volume: {volume_name}" in messages diff --git a/openstack_dashboard/test/selenium/ui/conftest.py b/openstack_dashboard/test/selenium/ui/conftest.py new file mode 100644 index 0000000000..39c9bebef7 --- /dev/null +++ b/openstack_dashboard/test/selenium/ui/conftest.py @@ -0,0 +1,90 @@ +# 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 unittest import mock +import warnings + +from django.test import testcases +import pytest +import requests + +from keystoneauth1.identity import v3 as v3_auth +from keystoneclient.v3 import client as client_v3 +from openstack_auth.tests import data_v3 +from openstack_dashboard import api + + +@pytest.fixture(autouse=True) +def no_warnings(): + warnings.simplefilter("ignore") + yield + warnings.simplefilter("default") + + +@pytest.fixture() +def auth_data(): + return data_v3.generate_test_data() + + +@pytest.fixture(autouse=True) +def disable_requests(monkeypatch): + class MockRequestsSession: + adapters = [] + + def request(self, *args, **kwargs): + raise RuntimeError("External request attempted, missed a mock?") + + monkeypatch.setattr(requests, 'Session', MockRequestsSession) + # enable request logging + monkeypatch.setattr(testcases, 'QuietWSGIRequestHandler', + testcases.WSGIRequestHandler) + +# prevent pytest-django errors due to no database +@pytest.fixture() +def _django_db_helper(): + pass + + +@pytest.fixture() +def mock_openstack_auth(settings, auth_data): + with mock.patch.object(client_v3, 'Client') as mock_client, \ + mock.patch.object(v3_auth, 'Token') as mock_token, \ + mock.patch.object(v3_auth, 'Password') as mock_password: + + keystone_url = settings.OPENSTACK_KEYSTONE_URL + auth_password = mock.Mock(auth_url=keystone_url) + mock_password.return_value = auth_password + auth_password.get_access.return_value = auth_data.unscoped_access_info + auth_token_unscoped = mock.Mock(auth_url=keystone_url) + auth_token_scoped = mock.Mock(auth_url=keystone_url) + mock_token.return_value = auth_token_scoped + auth_token_unscoped.get_access.return_value = ( + auth_data.federated_unscoped_access_info + ) + auth_token_scoped.get_access.return_value = ( + auth_data.unscoped_access_info + ) + client_unscoped = mock.Mock() + mock_client.return_value = client_unscoped + projects = [auth_data.project_one, auth_data.project_two] + client_unscoped.projects.list.return_value = projects + yield + + +@pytest.fixture() +def mock_keystoneclient(): + with mock.patch.object(api.keystone, 'keystoneclient') as mock_client: + keystoneclient = mock_client.return_value + endpoint_data = mock.Mock() + endpoint_data.api_version = (3, 10) + keystoneclient.session.get_endpoint_data.return_value = endpoint_data + yield diff --git a/openstack_dashboard/test/selenium/ui/test_settings.py b/openstack_dashboard/test/selenium/ui/test_settings.py new file mode 100644 index 0000000000..bcf670ea76 --- /dev/null +++ b/openstack_dashboard/test/selenium/ui/test_settings.py @@ -0,0 +1,43 @@ +# 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.selenium import widgets + + +def test_login(live_server, driver, mock_openstack_auth, mock_keystoneclient): + # We go to a page that doesn't do more api calls. + driver.get(live_server.url + '/settings') + assert driver.title == "Login - OpenStack Dashboard" + user_field = driver.find_element_by_id('id_username') + user_field.clear() + user_field.send_keys("user") + pass_field = driver.find_element_by_id('id_password') + pass_field.clear() + pass_field.send_keys("password") + button = driver.find_element_by_css_selector('div.panel-footer button.btn') + button.click() + errors = [m.text for m in + driver.find_elements_by_css_selector('div.alert-danger p')] + assert errors == [] + assert driver.title != "Login - OpenStack Dashboard" + + +def test_languages(live_server, driver, mock_openstack_auth, + mock_keystoneclient): + user_settings = driver.find_element_by_id('user_settings_modal') + language_options = user_settings.find_element_by_id('id_language') + language_options.click() + language_options.find_element_by_xpath("//option[@value='de']").click() + user_settings.find_element_by_xpath('//*[@class="btn btn-primary"]').click() + messages = widgets.get_and_dismiss_messages(driver) + assert "Success: Settings saved." in messages + assert "Error" not in messages +# ToDo - mock API switch page language. diff --git a/openstack_dashboard/test/selenium/widgets.py b/openstack_dashboard/test/selenium/widgets.py new file mode 100644 index 0000000000..4128f2d7e7 --- /dev/null +++ b/openstack_dashboard/test/selenium/widgets.py @@ -0,0 +1,49 @@ +# 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.by import By +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.wait import WebDriverWait + + +def get_and_dismiss_messages(element): + messages = element.find_elements_by_css_selector("div.messages div.alert") + collect = [] + for message in messages: + text = message.find_element_by_css_selector("p").text + message.find_element_by_css_selector("a.close").click() + collect.append(text) + return collect + + +def find_already_visible_element_by_xpath(element, driver): + return WebDriverWait(driver, 160).until( + EC.visibility_of_element_located((By.XPATH, element))) + + +def select_from_dropdown(element, label): + menu_button = element.find_element_by_css_selector( + "a[data-toggle='dropdown']" + ) + menu_button.click() + options = element.find_element_by_css_selector("ul.dropdown-menu") + selection = options.find_element_by_xpath( + f"li/button[text()[contains(.,'{label}')]]" + ) + selection.click() + + +def confirm_modal(element): + confirm = element.find_element_by_css_selector( + "#modal_wrapper a.btn-danger" + ) + confirm.click() diff --git a/tools/selenium_tests.sh b/tools/selenium_tests.sh index 1a1f77acc4..d5d953dbd9 100755 --- a/tools/selenium_tests.sh +++ b/tools/selenium_tests.sh @@ -3,5 +3,6 @@ ROOT=$1 report_args="--junitxml=$ROOT/test_reports/selenium_test_results.xml" report_args+=" --html=$ROOT/test_reports/selenium_test_results.html" report_args+=" --self-contained-html" +ignore="--ignore=$ROOT/openstack_dashboard/test/selenium" -pytest $ROOT/openstack_dashboard/ --ds=openstack_dashboard.test.settings -v -m selenium $report_args +pytest $ROOT/openstack_dashboard/ --ds=openstack_dashboard.test.settings $ignore -v -m selenium $report_args diff --git a/tools/unit_tests.sh b/tools/unit_tests.sh index 45da9e9b1d..90dd0e7a0c 100755 --- a/tools/unit_tests.sh +++ b/tools/unit_tests.sh @@ -29,6 +29,7 @@ function run_test { local target local settings_module local report_args + local ignore tag="not selenium and not integration and not plugin_test" @@ -61,14 +62,16 @@ function run_test { fi fi + ignore="--ignore=$root/openstack_dashboard/test/selenium" + if [ "$coverage" -eq 1 ]; then - coverage run -m pytest $target --ds=$settings_module -m "$tag" + coverage run -m pytest $target $ignore --ds=$settings_module -m "$tag" else report_args="--junitxml=$report_dir/${project}_test_results.xml" report_args+=" --html=$report_dir/${project}_test_results.html" report_args+=" --self-contained-html" - pytest $target --ds=$settings_module -v -m "$tag" $report_args + pytest $target --ds=$settings_module -v -m "$tag" $ignore $report_args fi return $? } diff --git a/tox.ini b/tox.ini index 59aacaab9c..6e7623c74d 100644 --- a/tox.ini +++ b/tox.ini @@ -107,6 +107,30 @@ commands = oslo-config-generator --namespace openstack_dashboard_integration_tests pytest --ds=openstack_dashboard.test.settings -v -x --junitxml="{toxinidir}/test_reports/integration_test_results.xml" --html="{toxinidir}/test_reports/integration_test_results.html" --self-contained-html {posargs:{toxinidir}/openstack_dashboard/test/integration_tests} +[testenv:integration-pytest] +envdir = {toxworkdir}/venv +# Run pytest integration tests only +passenv = + DISPLAY + FFMPEG_INSTALLED +setenv = + SELENIUM_HEADLESS=1 +commands = + oslo-config-generator --namespace openstack_dashboard_integration_tests + pytest -v --junitxml="{toxinidir}/test_reports/integration_pytest_results.xml" --html="{toxinidir}/test_reports/integration_pytest_results.html" --self-contained-html {posargs:{toxinidir}/openstack_dashboard/test/selenium/integration} + +[testenv:ui-pytest] +envdir = {toxworkdir}/venv +# Run pytest ui tests only +passenv = + DISPLAY + FFMPEG_INSTALLED +setenv = + SELENIUM_HEADLESS=1 +commands = + oslo-config-generator --namespace openstack_dashboard_integration_tests + pytest --ds=openstack_dashboard.settings -v --junitxml="{toxinidir}/test_reports/integration_uitest_results.xml" --html="{toxinidir}/test_reports/integration_uitest_results.html" --self-contained-html {posargs:{toxinidir}/openstack_dashboard/test/selenium/ui} + [testenv:npm] passenv = HOME