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
This commit is contained in:
Radomir Dopieralski 2023-07-31 13:58:58 +02:00
parent b4554a1951
commit 36536272ff
14 changed files with 945 additions and 12 deletions

View File

@ -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'),

View File

@ -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/

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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()

View File

@ -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

View File

@ -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 $?
}

24
tox.ini
View File

@ -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