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