Merge "Add Grafana CPU metrics test"
This commit is contained in:
commit
e5bdc8d9e6
|
@ -0,0 +1,189 @@
|
|||
# Copyright 2016 Mirantis, 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
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import contextlib
|
||||
|
||||
import selenium.common.exceptions as Exceptions
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.remote import webelement
|
||||
import selenium.webdriver.support.ui as Support
|
||||
from selenium.webdriver.support import expected_conditions
|
||||
from six.moves.urllib import parse
|
||||
|
||||
|
||||
class ImproperlyConfigured(Exception):
|
||||
"""Raises on some errors in pages classes configuration"""
|
||||
pass
|
||||
|
||||
|
||||
class BaseWebObject(object):
|
||||
def __init__(self, driver, timeout=5):
|
||||
self.driver = driver
|
||||
self.timeout = timeout
|
||||
|
||||
def _turn_off_implicit_wait(self):
|
||||
self.driver.implicitly_wait(0)
|
||||
|
||||
def _turn_on_implicit_wait(self):
|
||||
self.driver.implicitly_wait(self.timeout)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def waits_disabled(self):
|
||||
try:
|
||||
self._turn_off_implicit_wait()
|
||||
yield
|
||||
finally:
|
||||
self._turn_on_implicit_wait()
|
||||
|
||||
def _is_element_present(self, *locator):
|
||||
with self.waits_disabled():
|
||||
try:
|
||||
self._get_element(*locator)
|
||||
return True
|
||||
except Exceptions.NoSuchElementException:
|
||||
return False
|
||||
|
||||
def _is_element_visible(self, *locator):
|
||||
try:
|
||||
return self._get_element(*locator).is_displayed()
|
||||
except (Exceptions.NoSuchElementException,
|
||||
Exceptions.ElementNotVisibleException):
|
||||
return False
|
||||
|
||||
def _is_element_displayed(self, element):
|
||||
if element is None:
|
||||
return False
|
||||
try:
|
||||
if isinstance(element, webelement.WebElement):
|
||||
return element.is_displayed()
|
||||
else:
|
||||
return element.src_elem.is_displayed()
|
||||
except (Exceptions.ElementNotVisibleException,
|
||||
Exceptions.StaleElementReferenceException):
|
||||
return False
|
||||
|
||||
def _is_text_visible(self, element, text, strict=True):
|
||||
if not hasattr(element, 'text'):
|
||||
return False
|
||||
if strict:
|
||||
return element.text == text
|
||||
else:
|
||||
return text in element.text
|
||||
|
||||
def _get_element(self, *locator):
|
||||
return self.driver.find_element(*locator)
|
||||
|
||||
def _get_elements(self, *locator):
|
||||
return self.driver.find_elements(*locator)
|
||||
|
||||
def _fill_field_element(self, data, field_element):
|
||||
field_element.clear()
|
||||
field_element.send_keys(data)
|
||||
return field_element
|
||||
|
||||
def _select_dropdown(self, value, element):
|
||||
select = Support.Select(element)
|
||||
select.select_by_visible_text(value)
|
||||
|
||||
def _select_dropdown_by_value(self, value, element):
|
||||
select = Support.Select(element)
|
||||
select.select_by_value(value)
|
||||
|
||||
def _get_dropdown_options(self, element):
|
||||
select = Support.Select(element)
|
||||
return select.options
|
||||
|
||||
def get_action(self):
|
||||
return webdriver.ActionChains(self.driver)
|
||||
|
||||
|
||||
class PageObject(BaseWebObject):
|
||||
"""Base class for page objects."""
|
||||
|
||||
URL = None
|
||||
|
||||
def __init__(self, driver):
|
||||
"""Constructor."""
|
||||
super(PageObject, self).__init__(driver)
|
||||
self._page_title = None
|
||||
|
||||
@property
|
||||
def page_title(self):
|
||||
return self.driver.title
|
||||
|
||||
def open(self):
|
||||
if self.URL is None:
|
||||
raise ImproperlyConfigured('`open` method requires {!r} has '
|
||||
'not None URL class variable')
|
||||
url = parse.urljoin(self.get_current_page_url(), self.URL)
|
||||
self.driver.get(url)
|
||||
return self
|
||||
|
||||
@contextlib.contextmanager
|
||||
def wait_for_page_load(self, timeout=10):
|
||||
old_page = self.driver.find_element_by_tag_name('html')
|
||||
yield
|
||||
Support.WebDriverWait(
|
||||
self.driver,
|
||||
timeout).until(expected_conditions.staleness_of(old_page))
|
||||
|
||||
def is_the_current_page(self, do_assert=False):
|
||||
found_expected_title = self.page_title.startswith(self._page_title)
|
||||
if do_assert:
|
||||
err_msg = ("Expected to find %s in page title, instead found: %s" %
|
||||
(self._page_title, self.page_title))
|
||||
assert found_expected_title, err_msg
|
||||
return found_expected_title
|
||||
|
||||
def get_current_page_url(self):
|
||||
return self.driver.current_url
|
||||
|
||||
def close_window(self):
|
||||
return self.driver.close()
|
||||
|
||||
def is_nth_window_opened(self, n):
|
||||
return len(self.driver.window_handles) == n
|
||||
|
||||
def switch_window(self, name=None, index=None):
|
||||
"""Switches focus between the webdriver windows.
|
||||
Args:
|
||||
- name: The name of the window to switch to.
|
||||
- index: The index of the window handle to switch to.
|
||||
If the method is called without arguments it switches to the
|
||||
last window in the driver window_handles list.
|
||||
In case only one window exists nothing effectively happens.
|
||||
Usage:
|
||||
page.switch_window(name='_new')
|
||||
page.switch_window(index=2)
|
||||
page.switch_window()
|
||||
"""
|
||||
|
||||
if name is not None and index is not None:
|
||||
raise ValueError("switch_window receives the window's name or "
|
||||
"the window's index, not both.")
|
||||
if name is not None:
|
||||
self.driver.switch_to.window(name)
|
||||
elif index is not None:
|
||||
self.driver.switch_to.window(self.driver.window_handles[index])
|
||||
else:
|
||||
self.driver.switch_to.window(self.driver.window_handles[-1])
|
||||
|
||||
def go_to_previous_page(self):
|
||||
self.driver.back()
|
||||
|
||||
def go_to_next_page(self):
|
||||
self.driver.forward()
|
||||
|
||||
def refresh_page(self):
|
||||
self.driver.refresh()
|
|
@ -0,0 +1,205 @@
|
|||
# Copyright 2016 Mirantis, 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
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from selenium.webdriver.common import by
|
||||
|
||||
from fuel_ccp_tests.helpers.ui import base_pages
|
||||
|
||||
|
||||
class LoginPage(base_pages.PageObject):
|
||||
|
||||
_login_username_field_locator = (by.By.NAME, 'username')
|
||||
_login_password_field_locator = (by.By.NAME, 'password')
|
||||
_login_submit_button_locator = (by.By.CLASS_NAME, "btn")
|
||||
|
||||
URL = '/login'
|
||||
|
||||
def __init__(self, driver):
|
||||
super(LoginPage, self).__init__(driver)
|
||||
self._page_title = "Grafana"
|
||||
|
||||
@property
|
||||
def username_field(self):
|
||||
return self._get_element(*self._login_username_field_locator)
|
||||
|
||||
@property
|
||||
def password_field(self):
|
||||
return self._get_element(*self._login_password_field_locator)
|
||||
|
||||
@property
|
||||
def login_button(self):
|
||||
return self._get_element(*self._login_submit_button_locator)
|
||||
|
||||
def login(self, username, password):
|
||||
self.open()
|
||||
self._fill_field_element(username, self.username_field)
|
||||
self._fill_field_element(password, self.password_field)
|
||||
with self.wait_for_page_load():
|
||||
self.login_button.click()
|
||||
|
||||
page = MainPage(self.driver)
|
||||
page.is_the_current_page(do_assert=True)
|
||||
return page
|
||||
|
||||
|
||||
class DashboardPage(base_pages.PageObject):
|
||||
_submenu_controls_locator = (by.By.CLASS_NAME, "submenu-controls")
|
||||
_submenu_item_xpath_tpl = (
|
||||
'.//*[contains(@class, "submenu-item")]'
|
||||
'[.//*[text()="{}"]]'
|
||||
'//*[contains(@class, "variable-link-wrapper")]')
|
||||
_hostname_selector = (by.By.XPATH,
|
||||
_submenu_item_xpath_tpl.format('Hostname:'))
|
||||
_disk_selector = (by.By.XPATH, _submenu_item_xpath_tpl.format('Disks:'))
|
||||
_interface_selector = (by.By.XPATH,
|
||||
_submenu_item_xpath_tpl.format('Interface:'))
|
||||
_filesystem_selector = (by.By.XPATH,
|
||||
_submenu_item_xpath_tpl.format('Filesystem:'))
|
||||
_variable_option_selector = (by.By.CLASS_NAME, 'variable-option')
|
||||
|
||||
_panel_selector = (by.By.TAG_NAME, 'grafana-panel')
|
||||
_panel_title_text_selector = (by.By.CLASS_NAME, 'panel-title-text')
|
||||
|
||||
_tooltip_selector = (by.By.ID, 'tooltip')
|
||||
|
||||
_tooltip_series_name_selector = (by.By.CLASS_NAME,
|
||||
'graph-tooltip-series-name')
|
||||
_tooltip_series_value_selector = (by.By.CLASS_NAME, 'graph-tooltip-value')
|
||||
|
||||
def __init__(self, driver, dashboard_name):
|
||||
super(DashboardPage, self).__init__(driver)
|
||||
self._page_title = "Grafana - {}".format(dashboard_name)
|
||||
|
||||
def is_dashboards_page(self):
|
||||
return (self.is_the_current_page() and
|
||||
self._is_element_visible(*self._submenu_controls_locator))
|
||||
|
||||
def get_back_to_home(self):
|
||||
self.go_to_previous_page()
|
||||
|
||||
page = MainPage(self.driver)
|
||||
page.is_the_current_page(do_assert=True)
|
||||
return page
|
||||
|
||||
@property
|
||||
def submenu(self):
|
||||
return self._get_element(*self._submenu_controls_locator)
|
||||
|
||||
def _get_submenu_list(self, selector):
|
||||
submenu_item_list = self.submenu.find_element(*selector)
|
||||
submenu_item_list.click()
|
||||
return submenu_item_list.find_elements(*self._variable_option_selector)
|
||||
|
||||
def _get_submenu_items_names(self, selector):
|
||||
return [x.text for x in self._get_submenu_list(selector)]
|
||||
|
||||
def _choose_submenu_item_value(self, selector, value):
|
||||
list_items = self._get_submenu_list(selector)
|
||||
mapping = {x.text.lower(): x for x in list_items}
|
||||
mapping[value].click()
|
||||
|
||||
def get_hostnames_list(self):
|
||||
return self._get_submenu_items_names(self._hostname_selector)
|
||||
|
||||
def choose_hostname(self, value):
|
||||
return self._choose_submenu_item_value(self._hostname_selector, value)
|
||||
|
||||
def get_disks_list(self):
|
||||
return self._get_submenu_items_names(self._disk_selector)
|
||||
|
||||
def choose_disk(self, value):
|
||||
return self._choose_submenu_item_value(self._disk_selector, value)
|
||||
|
||||
def get_interfaces_list(self):
|
||||
return self._get_submenu_items_names(self._interface_selector)
|
||||
|
||||
def choose_interface(self, value):
|
||||
return self._choose_submenu_item_value(self._interface_selector, value)
|
||||
|
||||
def get_filesystems_list(self):
|
||||
return self._get_submenu_items_names(self._filesystem_selector)
|
||||
|
||||
def choose_filesystem(self, value):
|
||||
return self._choose_submenu_item_value(self._filesystem_selector,
|
||||
value)
|
||||
|
||||
def _get_panels_mapping(self):
|
||||
panels = self._get_elements(*self._panel_selector)
|
||||
return {x.find_element(*self._panel_title_text_selector).text: x
|
||||
for x in panels}
|
||||
|
||||
def get_cpu_panel(self):
|
||||
return self._get_panels_mapping()['CPU']
|
||||
|
||||
def get_panel_tooltip(self, panel):
|
||||
size = panel.size
|
||||
action = self.get_action()
|
||||
action.move_to_element_with_offset(panel, size['width'] / 2,
|
||||
size['height'] / 2)
|
||||
action.perform()
|
||||
return self._get_element(*self._tooltip_selector)
|
||||
|
||||
def get_tooltop_values(self, tooltip):
|
||||
result = {}
|
||||
series_names = tooltip.find_elements(
|
||||
*self._tooltip_series_name_selector)
|
||||
series_values = tooltip.find_elements(
|
||||
*self._tooltip_series_value_selector)
|
||||
for series_name, series_value in zip(series_names, series_values):
|
||||
result[series_name.text.strip(':')] = series_value.text
|
||||
return result
|
||||
|
||||
|
||||
class MainPage(base_pages.PageObject):
|
||||
_dropdown_menu_locator = (by.By.LINK_TEXT, 'Home')
|
||||
|
||||
_dashboards_list_locator = (by.By.CLASS_NAME, 'search-results-container')
|
||||
|
||||
_dashboard_locator = (by.By.CLASS_NAME, 'search-result-link')
|
||||
|
||||
def __init__(self, driver):
|
||||
super(MainPage, self).__init__(driver)
|
||||
self._page_title = "Grafana - Home"
|
||||
|
||||
def is_main_page(self):
|
||||
return (self.is_the_current_page() and
|
||||
self._is_element_visible(*self._dropdown_menu_locator))
|
||||
|
||||
@property
|
||||
def dropdown_menu(self):
|
||||
return self._get_element(*self._dropdown_menu_locator)
|
||||
|
||||
@property
|
||||
def dashboards_list(self):
|
||||
self.open_dropdown_menu()
|
||||
return self._get_element(*self._dashboards_list_locator)
|
||||
|
||||
@property
|
||||
def dashboards(self):
|
||||
return self.dashboards_list.find_elements(*self._dashboard_locator)
|
||||
|
||||
def is_dropdown_menu_opened(self):
|
||||
return self._is_element_present(*self._dashboards_list_locator)
|
||||
|
||||
def open_dropdown_menu(self):
|
||||
if not self.is_dropdown_menu_opened():
|
||||
self.dropdown_menu.click()
|
||||
|
||||
def open_dashboard(self, dashboard_name):
|
||||
dashboards_mapping = {dashboard.text.lower(): dashboard
|
||||
for dashboard in self.dashboards}
|
||||
dashboards_mapping[dashboard_name.lower()].click()
|
||||
dashboard_page = DashboardPage(self.driver, dashboard_name)
|
||||
dashboard_page.is_dashboards_page()
|
||||
return dashboard_page
|
|
@ -0,0 +1,35 @@
|
|||
# Copyright 2016 Mirantis, 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
|
||||
#
|
||||
# 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
|
||||
|
||||
|
||||
# Use a virtual display server for running the tests headless or not
|
||||
headless_mode = os.environ.get('SELENIUM_HEADLESS', False)
|
||||
|
||||
# The browser session will be started with given proxy,
|
||||
# can be useful if you try to start UI tests on developer machine,
|
||||
# but environment is on remote server
|
||||
proxy_address = os.environ.get('DRIVER_PROXY', None)
|
||||
|
||||
# Maximize the current window that webdriver is using or not
|
||||
maximize_window = os.environ.get('SELENIUM_MAXIMIZE', True)
|
||||
|
||||
# Sets a sticky timeout to implicitly wait for an element to be found,
|
||||
# or a command to complete.
|
||||
implicit_wait = os.environ.get('IMPLICIT_WAIT', 5)
|
||||
|
||||
# Set the amount of time to wait for a page load to complete
|
||||
# before throwing an error.
|
||||
page_timeout = os.environ.get('PAGE_TIMEOUT', 15)
|
|
@ -0,0 +1,79 @@
|
|||
# Copyright 2016 Mirantis, 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
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import contextlib
|
||||
import socket
|
||||
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common import by
|
||||
from selenium.webdriver.common import proxy
|
||||
import xvfbwrapper
|
||||
|
||||
from fuel_ccp_tests.helpers.ui import ui_settings
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def ui_driver(url):
|
||||
vdisplay = None
|
||||
# Start a virtual display server for running the tests headless.
|
||||
if ui_settings.headless_mode:
|
||||
vdisplay = xvfbwrapper.Xvfb(width=1920, height=1080)
|
||||
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()
|
||||
driver = get_driver(url)
|
||||
try:
|
||||
yield driver
|
||||
finally:
|
||||
driver.quit()
|
||||
if vdisplay is not None:
|
||||
vdisplay.stop()
|
||||
|
||||
|
||||
def get_driver(url, anchor='/html', by_selector_type=by.By.XPATH):
|
||||
proxy_address = ui_settings.proxy_address
|
||||
# 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.
|
||||
proxy_ex = None
|
||||
if proxy_address is not None:
|
||||
proxy_ex = proxy.Proxy(
|
||||
{
|
||||
'proxyType': proxy.ProxyType.MANUAL,
|
||||
'socksProxy': proxy_address,
|
||||
}
|
||||
)
|
||||
driver = webdriver.Firefox(proxy=proxy_ex)
|
||||
if ui_settings.maximize_window:
|
||||
driver.maximize_window()
|
||||
driver.implicitly_wait(ui_settings.implicit_wait)
|
||||
driver.set_page_load_timeout(ui_settings.page_timeout)
|
||||
driver.get(url)
|
||||
driver.find_element(by_selector_type, anchor)
|
||||
return driver
|
|
@ -9,3 +9,5 @@ docker-compose==1.7.1
|
|||
urllib3
|
||||
psycopg2
|
||||
python-k8sclient==0.3.0
|
||||
selenium
|
||||
xvfbwrapper
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
# Copyright 2016 Mirantis, 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
|
||||
#
|
||||
# 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 re
|
||||
|
||||
import pytest
|
||||
|
||||
from fuel_ccp_tests.helpers import ext
|
||||
from fuel_ccp_tests.helpers.ui import ui_tester
|
||||
from fuel_ccp_tests.helpers.ui import grafana_pages
|
||||
|
||||
|
||||
@pytest.yield_fixture
|
||||
def driver(request):
|
||||
url = os.environ.get('GRAFANA_URL')
|
||||
if url is None:
|
||||
underlay = request.getfixturevalue('underlay')
|
||||
nodes = underlay.node_names()
|
||||
ip = underlay.host_by_node_name(nodes[0])
|
||||
k8s_actions = request.getfixturevalue('k8s_actions')
|
||||
k8sclient = k8s_actions.api
|
||||
service = k8sclient.services.get('grafana', namespace='ccp')
|
||||
port = service.spec.ports[0].node_port
|
||||
url = "http://{}:{}/".format(ip, port)
|
||||
with ui_tester.ui_driver(url) as driver:
|
||||
yield driver
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def system_dashboard(request, driver):
|
||||
"""Login and return grafana system dashboard page"""
|
||||
login_page = grafana_pages.LoginPage(driver).open()
|
||||
main_page = login_page.login(username='admin', password='admin')
|
||||
return main_page.open_dashboard('system')
|
||||
|
||||
|
||||
@pytest.mark.revert_snapshot(ext.SNAPSHOT.ccp_deployed)
|
||||
class TestGrafana(object):
|
||||
def test_cpu_metrics(self, system_dashboard):
|
||||
"""Check CPU metrics on Grafana system dashboard
|
||||
|
||||
Scenario:
|
||||
* Login to Grafana
|
||||
* Go to system dashboard page
|
||||
* Select 1'st hostname
|
||||
* Move mouse to CPU graph
|
||||
* Check that "user", "system", "idle" values are present on tooltip
|
||||
* Repeat last 3 steps for each hostname
|
||||
"""
|
||||
for host in system_dashboard.get_hostnames_list():
|
||||
system_dashboard.choose_hostname(host)
|
||||
cpu_panel = system_dashboard.get_cpu_panel()
|
||||
tooltip = system_dashboard.get_panel_tooltip(cpu_panel)
|
||||
tooltip_values = system_dashboard.get_tooltop_values(tooltip)
|
||||
for key in ("user", "system", "idle"):
|
||||
err_msg = ("Grafana CPU panel tooltip "
|
||||
"doesn't contains {} value").format(key)
|
||||
assert key in tooltip_values, err_msg
|
||||
err_msg = ("Grafana CPU panel tooltip value for {} "
|
||||
"is 0% or has wrong format").format(key)
|
||||
assert re.search(r'[1-9][0-9]*?%',
|
||||
tooltip_values[key]) is not None, err_msg
|
Loading…
Reference in New Issue