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:
parent
b4554a1951
commit
36536272ff
@ -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'),
|
||||
|
@ -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/
|
||||
|
||||
|
233
openstack_dashboard/test/selenium/conftest.py
Normal file
233
openstack_dashboard/test/selenium/conftest.py
Normal 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()
|
59
openstack_dashboard/test/selenium/integration/conftest.py
Normal file
59
openstack_dashboard/test/selenium/integration/conftest.py
Normal 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()
|
277
openstack_dashboard/test/selenium/integration/test_instances.py
Normal file
277
openstack_dashboard/test/selenium/integration/test_instances.py
Normal 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
|
@ -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()
|
146
openstack_dashboard/test/selenium/integration/test_volumes.py
Normal file
146
openstack_dashboard/test/selenium/integration/test_volumes.py
Normal 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
|
90
openstack_dashboard/test/selenium/ui/conftest.py
Normal file
90
openstack_dashboard/test/selenium/ui/conftest.py
Normal 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
|
43
openstack_dashboard/test/selenium/ui/test_settings.py
Normal file
43
openstack_dashboard/test/selenium/ui/test_settings.py
Normal 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.
|
49
openstack_dashboard/test/selenium/widgets.py
Normal file
49
openstack_dashboard/test/selenium/widgets.py
Normal 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()
|
@ -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
|
||||
|
@ -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
24
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
|
||||
|
Loading…
Reference in New Issue
Block a user