├── .github └── workflows │ ├── deploy.yaml │ └── tests.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.rst ├── docs ├── conf.py └── index.rst ├── setup.cfg ├── setup.py ├── src └── widgetastic_patternfly │ ├── __init__.py │ └── utils.py └── testing ├── README.rst ├── __init__.py ├── conftest.py ├── test_about_modal.py ├── test_agg_status_card.py ├── test_all_components.py ├── test_bootstrap_nav.py ├── test_bootstrap_tree.py ├── test_buttons.py ├── test_data_visualization.py ├── test_date_picker.py ├── test_dropdowns.py ├── test_flashmessages.py ├── test_modal.py └── testing_page.html /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - '*' 9 | 10 | jobs: 11 | build-and-publish: 12 | name: Build and publish Python distributions to PyPI 13 | if: startsWith(github.event.ref, 'refs/tags') 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v2 18 | 19 | - name: Setup Python 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: '3.8' 23 | architecture: 'x64' 24 | 25 | - name: Build Package and Check 26 | run: | 27 | python -m pip install --upgrade setuptools wheel twine 28 | python setup.py sdist bdist_wheel 29 | python -m twine check dist/* 30 | 31 | - name: Deploy to PyPi 32 | uses: pypa/gh-action-pypi-publish@release/v1 33 | with: 34 | user: __token__ 35 | password: ${{ secrets.pypi_wt_pf }} 36 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Widgetastic.patternfly Test suite 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: ["opened", "synchronize", "reopened"] 9 | 10 | jobs: 11 | codechecks: 12 | name: Code quality [pre-commit] 13 | runs-on: ubuntu-20.04 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v2 18 | 19 | - name: Setup Python 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: "3.8" 23 | architecture: "x64" 24 | 25 | - name: Pre Commit Checks 26 | uses: pre-commit/action@v2.0.0 27 | 28 | - name: Analysis (git diff) 29 | if: failure() 30 | run: git diff 31 | 32 | unit-tests: 33 | # Run unit tests on different version of python and browser 34 | name: Python-${{ matrix.python-version }}-${{ matrix.browser }} 35 | runs-on: ubuntu-20.04 36 | needs: [codechecks] 37 | strategy: 38 | matrix: 39 | browser: [chrome, firefox] 40 | python-version: [3.8, 3.9] 41 | 42 | steps: 43 | - name: Pull selenium-standalone:latest 44 | run: podman pull quay.io/redhatqe/selenium-standalone:latest 45 | 46 | - name: Pull docker.io/library/nginx:alpine 47 | run: podman pull docker.io/library/nginx:alpine 48 | 49 | - name: Checkout 50 | uses: actions/checkout@v2 51 | 52 | - name: Set up Python-${{ matrix.python-version }} 53 | uses: actions/setup-python@v2 54 | with: 55 | python-version: ${{ matrix.python-version }} 56 | 57 | - name: UnitTest - Python-${{ matrix.python-version }}-${{ matrix.browser }} 58 | env: 59 | BROWSER: ${{ matrix.browser }} 60 | XDG_RUNTIME_DIR: ${{ github.workspace }} 61 | run: | 62 | pip install -U setuptools wheel 63 | pip install -e .[test] 64 | mkdir -p ${XDG_RUNTIME_DIR}/podman 65 | podman system service --time=0 unix://${XDG_RUNTIME_DIR}/podman/podman.sock & 66 | pytest -v --cov widgetastic_patternfly --cov-report term-missing --alluredir allure/ 67 | 68 | docs: 69 | name: Docs Build 70 | needs: [unit-tests] 71 | runs-on: ubuntu-latest 72 | steps: 73 | - name: Checkout 74 | uses: actions/checkout@v2 75 | 76 | - name: Setup Python 77 | uses: actions/setup-python@v2 78 | with: 79 | python-version: "3.8" 80 | 81 | - name: Install Deps 82 | run: | 83 | pip install -U pip setuptools wheel 84 | pip install .[docs] 85 | 86 | - name: Build Docs 87 | run: sphinx-build -b html -d build/sphinx-doctrees docs build/htmldocs 88 | 89 | - name: Archive Docs 90 | uses: actions/upload-artifact@v2 91 | with: 92 | name: sphinx-htmldocs 93 | path: build/htmldocs 94 | 95 | package: 96 | name: Build & Verify Package 97 | needs: [unit-tests] 98 | runs-on: ubuntu-latest 99 | 100 | steps: 101 | - name: Checkout 102 | uses: actions/checkout@v2 103 | 104 | - name: Setup Python 105 | uses: actions/setup-python@v2 106 | with: 107 | python-version: "3.8" 108 | architecture: "x64" 109 | 110 | - name: Build and verify with twine 111 | run: | 112 | python -m pip install pip --upgrade 113 | pip install twine setuptools wheel --upgrade 114 | python setup.py sdist bdist_wheel 115 | ls -l dist 116 | python -m twine check dist/* 117 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | allure/ 92 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/asottile/reorder_python_imports 3 | rev: v3.1.0 4 | hooks: 5 | - id: reorder-python-imports 6 | language_version: python3 7 | args: 8 | - --application-directories=.:src 9 | - repo: https://github.com/psf/black 10 | rev: 22.3.0 11 | hooks: 12 | - id: black 13 | args: [--safe, --quiet, --line-length, "100"] 14 | language_version: python3 15 | require_serial: true 16 | - repo: https://github.com/PyCQA/flake8 17 | rev: 4.0.1 18 | hooks: 19 | - id: flake8 20 | language_version: python3 21 | args: 22 | - --max-line-length=100 23 | - --ignore=W503,E203 24 | - repo: https://github.com/pre-commit/pre-commit-hooks 25 | rev: v4.2.0 26 | hooks: 27 | - id: trailing-whitespace 28 | language_version: python3 29 | - id: end-of-file-fixer 30 | language_version: python3 31 | - id: debug-statements 32 | language_version: python3 33 | - repo: https://github.com/asottile/pyupgrade 34 | rev: v2.32.1 35 | hooks: 36 | - id: pyupgrade 37 | language_version: python3 38 | args: [--py3-plus, --py38-plus] 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Red Hat, Inc. and/or its affiliates 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====================== 2 | widgetastic.patternfly 3 | ====================== 4 | 5 | .. image:: https://travis-ci.org/RedHatQE/widgetastic.patternfly.svg?branch=master 6 | :target: https://travis-ci.org/RedHatQE/widgetastic.patternfly 7 | 8 | .. image:: https://coveralls.io/repos/github/RedHatQE/widgetastic.patternfly/badge.svg?branch=master 9 | :target: https://coveralls.io/github/RedHatQE/widgetastic.patternfly?branch=master 10 | 11 | .. image:: https://readthedocs.org/projects/widgetastic-patternfly/badge/?version=latest 12 | :target: http://widgetastic-patternfly.readthedocs.io/en/latest/?badge=latest 13 | :alt: Documentation Status 14 | 15 | Patternfly_ widget library for Widgetastic_. 16 | 17 | .. _Patternfly: http://www.patternfly.org 18 | .. _Widgetastic: https://github.com/RedHatQE/widgetastic.core 19 | 20 | Written originally by Milan Falesnik (mfalesni@redhat.com, http://www.falesnik.net/) and 21 | other contributors since 2016. 22 | 23 | Contributors whose contributions were squashed during the library move in order of their first commit: 24 | 25 | - Ievgen Zapolskyi 26 | - Pete Savage 27 | - Dmitry Misharov 28 | - Oleksii Tsuman 29 | - Mike Shriver 30 | 31 | Usage 32 | ===== 33 | 34 | .. code-block:: python 35 | 36 | from widgetastic.widget import View 37 | from widgetastic_patternfly import Button 38 | 39 | class SomeView(View): 40 | add = Button('Add', classes=[Button.PRIMARY]) 41 | 42 | Check the ``src/widgetastic_patternfly/__init__.py`` for more documentation. 43 | 44 | Currently all the PatternFly widgets are located in the ``widgetastic_patternfly`` package. 45 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from datetime import datetime 4 | 5 | import pkg_resources 6 | 7 | __distribution = pkg_resources.get_distribution("widgetastic.patternfly") 8 | 9 | # update sys.path so autodoc can import the modules 10 | modules_path = os.path.abspath("../src/widgetastic_patternfly") 11 | sys.path.insert(0, modules_path) 12 | 13 | extensions = [ 14 | "sphinx.ext.autodoc", 15 | "sphinx.ext.napoleon", 16 | ] 17 | 18 | master_doc = "index" 19 | 20 | # General information about the project. 21 | project = __distribution.project_name 22 | copyright = f"2016-{datetime.now().year}, Milan Falešník (Apache license 2)" 23 | author = "Milan Falešník" 24 | 25 | 26 | # The full version, including alpha/beta/rc tags. 27 | release = __distribution.version 28 | version = ".".join(release.split(".")[:2]) 29 | 30 | exclude_patterns = ["_build"] 31 | 32 | html_theme = "default" 33 | 34 | templates_path = ["_templates"] 35 | 36 | 37 | def run_apidoc(_): 38 | from sphinx.ext.apidoc import main as apidoc_main 39 | 40 | cur_dir = os.path.abspath(".") 41 | output_path = os.path.join(cur_dir, "source") 42 | apidoc_main(["-e", "-f", "-o", output_path, modules_path, "--force"]) 43 | 44 | 45 | def setup(app): 46 | app.connect("builder-inited", run_apidoc) 47 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | 2 | Welcome to widgetastic.patternfly's documentation! 3 | ================================================== 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | :caption: Contents: 8 | 9 | 10 | Indices and tables 11 | ================== 12 | 13 | * :ref:`genindex` 14 | * :ref:`modindex` 15 | * :ref:`search` 16 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = widgetastic.patternfly 3 | author = Milan Falesnik 4 | author_email = mfalesni@redhat.com 5 | maintainer = RedHatQE 6 | maintainer_email = mshriver@redhat.com 7 | description= Patternfly widget library for Widgetastic 8 | long_description = file: README.rst 9 | long_description_content_type=text/x-rst 10 | license= Apache license 11 | url= https://github.com/RedHatQE/widgetastic.patternfly 12 | classifiers= 13 | Programming Language :: Python 14 | Programming Language :: Python :: 3 15 | Programming Language :: Python :: 3.8 16 | Topic :: Software Development :: Libraries :: Python Modules 17 | Topic :: Software Development :: Quality Assurance 18 | Topic :: Software Development :: Testing 19 | 20 | [options] 21 | python_requires = >= 3.8 22 | install_requires = 23 | widgetastic.core>=1.0.0 24 | setup_requires = setuptools_scm 25 | package_dir = 26 | =src 27 | packages=find: 28 | 29 | [options.packages.find] 30 | where=src 31 | 32 | [options.extras_require] 33 | test = 34 | allure-pytest 35 | coveralls 36 | podman 37 | pytest 38 | pytest_httpserver 39 | pytest-cov 40 | pytest-xdist 41 | dev = 42 | podman 43 | pre-commit 44 | pytest 45 | pytest-xdist 46 | pytest_httpserver 47 | pytest-cov 48 | docs = 49 | sphinx 50 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | setup( 5 | use_scm_version=True, 6 | ) 7 | -------------------------------------------------------------------------------- /src/widgetastic_patternfly/__init__.py: -------------------------------------------------------------------------------- 1 | """This package contains classes that represent widgets in Patternfly for Widgetastic""" 2 | import functools 3 | import re 4 | import time 5 | from collections import namedtuple 6 | from datetime import datetime 7 | from operator import not_ 8 | 9 | from cached_property import cached_property 10 | from wait_for import TimedOutError 11 | from wait_for import wait_for 12 | from wait_for import wait_for_decorator 13 | from widgetastic.exceptions import LocatorNotImplemented 14 | from widgetastic.exceptions import NoSuchElementException 15 | from widgetastic.exceptions import StaleElementReferenceException 16 | from widgetastic.exceptions import UnexpectedAlertPresentException 17 | from widgetastic.exceptions import WidgetOperationFailed 18 | from widgetastic.log import call_sig 19 | from widgetastic.utils import Parameter 20 | from widgetastic.utils import ParametrizedLocator 21 | from widgetastic.utils import partial_match 22 | from widgetastic.utils import VersionPick 23 | from widgetastic.widget import BaseInput 24 | from widgetastic.widget import ClickableMixin 25 | from widgetastic.widget import do_not_read_this_widget 26 | from widgetastic.widget import ParametrizedView 27 | from widgetastic.widget import Table 28 | from widgetastic.widget import Text 29 | from widgetastic.widget import TextInput 30 | from widgetastic.widget import View 31 | from widgetastic.widget import Widget 32 | from widgetastic.xpath import quote 33 | 34 | from .utils import PFIcon 35 | 36 | # py3.7+ module change 37 | try: 38 | Pattern = re.Pattern 39 | except AttributeError: 40 | Pattern = re._pattern_type 41 | 42 | 43 | def retry_element(method): 44 | """Decorator to invoke method one or more times, if StaleElementReferenceException or 45 | NoSuchElementException are raised. 46 | """ 47 | 48 | @functools.wraps(method) 49 | def retry_element_wrapper(*args, **kwargs): 50 | attempts = 10 51 | for i in range(attempts): 52 | try: 53 | return method(*args, **kwargs) 54 | except (StaleElementReferenceException, NoSuchElementException): 55 | if i < attempts - 1: 56 | time.sleep(0.5) 57 | else: 58 | raise 59 | 60 | return retry_element_wrapper 61 | 62 | 63 | class CandidateNotFound(Exception): 64 | """ 65 | Raised if there is no candidate found whilst trying to traverse a tree. 66 | """ 67 | 68 | def __init__(self, d): 69 | self.d = d 70 | 71 | @property 72 | def message(self): 73 | return ", ".join(f"{k}: {v}" for k, v in self.d.items()) 74 | 75 | def __str__(self): 76 | return self.message 77 | 78 | 79 | class DropdownDisabled(Exception): 80 | pass 81 | 82 | 83 | class DropdownItemDisabled(Exception): 84 | pass 85 | 86 | 87 | class DropdownItemNotFound(Exception): 88 | pass 89 | 90 | 91 | class SelectItemNotFound(Exception): 92 | def __init__(self, widget, item, options=None): 93 | self.widget = widget 94 | self.item = item 95 | self.options = options 96 | 97 | @property 98 | def message(self): 99 | return "Could not find {!r} in {!r}\n" "These options are present: {!r}".format( 100 | self.item, self.widget, ", ".join(self.options) 101 | ) 102 | 103 | def __str__(self): 104 | return self.message 105 | 106 | 107 | class Button(Widget, ClickableMixin): 108 | """A PatternFly/Bootstrap button 109 | 110 | You can match by text, partial text or by attributes, you can also add the bootstrap classes 111 | into the matching. 112 | 113 | .. code-block:: python 114 | 115 | Button('Text of button (unless it is an input ...)') 116 | Button('contains', 'Text of button (unless it is an input ...)') 117 | Button(title='Show xyz') # And such 118 | Button('Add', classes=[Button.PRIMARY]) 119 | assert button.active 120 | assert not button.disabled 121 | """ 122 | 123 | CHECK_VISIBILITY = True 124 | 125 | # Classes usable in the constructor 126 | # Button types 127 | DEFAULT = "btn-default" 128 | PRIMARY = "btn-primary" 129 | SUCCESS = "btn-success" 130 | INFO = "btn-info" 131 | WARNING = "btn-warning" 132 | DANGER = "btn-danger" 133 | LINK = "btn-link" 134 | 135 | # Button sizes 136 | LARGE = "btn-lg" 137 | MEDIUM = "btn-md" 138 | SMALL = "btn-sm" 139 | EXTRA_SMALL = "btn-xs" 140 | 141 | # Shape 142 | BLOCK = "btn-block" 143 | 144 | def __init__(self, parent, *text, **kwargs): 145 | logger = kwargs.pop("logger", None) 146 | Widget.__init__(self, parent, logger=logger) 147 | self.args = text 148 | self.kwargs = kwargs 149 | classes = kwargs.pop("classes", []) 150 | if text: 151 | if kwargs: # classes should have been the only kwarg combined with text args 152 | raise TypeError("If you pass button text then only pass classes in addition") 153 | if len(text) == 1: 154 | self.locator_conditions = f"normalize-space(.)={quote(text[0])}" 155 | elif len(text) == 2 and text[0].lower() == "contains": 156 | self.locator_conditions = f"contains(normalize-space(.), {quote(text[1])})" 157 | else: 158 | raise TypeError("An illegal combination of text params") 159 | else: 160 | # Join the kwargs, if any 161 | self.locator_conditions = " and ".join( 162 | [f"@{attr}={quote(value)}" for attr, value in kwargs.items()] 163 | ) 164 | 165 | if classes: 166 | if self.locator_conditions: 167 | self.locator_conditions += " and " 168 | self.locator_conditions += " and ".join( 169 | f"contains(@class, {quote(klass)})" for klass in classes 170 | ) 171 | if self.locator_conditions: 172 | self.locator_conditions = f"and ({self.locator_conditions})" 173 | 174 | # TODO: Handle input value the same way as text for other tags 175 | def __locator__(self): 176 | return ( 177 | './/*[(self::a or self::button or (self::input and (@type="button" or @type="submit")))' 178 | ' and contains(@class, "btn") {}]'.format(self.locator_conditions) 179 | ) 180 | 181 | @property 182 | def active(self): 183 | return "active" in self.browser.classes(self) 184 | 185 | @property 186 | def disabled(self): 187 | return ( 188 | "disabled" in self.browser.classes(self) 189 | or self.browser.get_attribute("disabled", self) == "disabled" 190 | or self.browser.get_attribute("disabled", self) == "true" 191 | ) 192 | 193 | def __repr__(self): 194 | return f"{type(self).__name__}{call_sig(self.args, self.kwargs)}" 195 | 196 | @property 197 | def title(self): 198 | return self.browser.get_attribute("title", self) 199 | 200 | def fill(self, value): 201 | if value: 202 | self.click() 203 | return True 204 | else: 205 | return False 206 | 207 | @property 208 | def text(self): 209 | """Return the element text, not the passed text""" 210 | return self.browser.text(self) 211 | 212 | def read(self): 213 | """Widget.read override, use text""" 214 | return self.text 215 | 216 | 217 | class ViewChangeButton(Widget, ClickableMixin): 218 | """A PatternFly/Bootstrap view selection button in CFME 56z 219 | 220 | .. code-block:: python 221 | 222 | ViewChangeButton(title='Grid View') 223 | assert button.active 224 | """ 225 | 226 | CHECK_VISIBILITY = True 227 | 228 | def __init__(self, parent, title, **kwargs): 229 | Widget.__init__(self, parent, logger=kwargs.pop("logger", None)) 230 | self.title = title 231 | 232 | def __locator__(self): 233 | return f'.//a[(@title={quote(self.title)}) and i[contains(@class, "fa")]]' 234 | 235 | @property 236 | def active(self): 237 | return "active" in self.browser.classes("..", parent=self) 238 | 239 | 240 | class Input(TextInput): 241 | """Patternfly input 242 | 243 | Has some additional methods. 244 | """ 245 | 246 | WARNING_LOCATOR = "./following-sibling::div" 247 | HELP_BLOCK_LOCATOR = "./following-sibling::span" 248 | 249 | @property 250 | def help_block(self): 251 | e = self.browser.element(self) 252 | try: 253 | help_block = self.browser.element(self.HELP_BLOCK_LOCATOR, parent=e) 254 | except NoSuchElementException: 255 | return None 256 | else: 257 | return self.browser.text(help_block) 258 | 259 | @property 260 | def warning(self): 261 | try: 262 | self.browser.wait_for_element(self.WARNING_LOCATOR, timeout=3) 263 | return self.browser.text(self.WARNING_LOCATOR) 264 | except NoSuchElementException: 265 | return None 266 | 267 | 268 | class NavDropdown(Widget, ClickableMixin): 269 | """The dropdowns used eg. in navigation. Usually located in the top navbar.""" 270 | 271 | EXPAND_LOCATOR = ( 272 | './a["aria-expanded" and ' '"aria-haspopup" and ' 'contains(@class, "dropdown-toggle")]' 273 | ) 274 | TEXT_LOCATOR = "./a//p" 275 | 276 | ROOT = ParametrizedLocator( 277 | "//nav" 278 | '//li[.//a[@id={@id|quote} and contains(@class, "dropdown-toggle")]' 279 | ' and contains(@class, "dropdown")]' 280 | ) 281 | 282 | def __init__(self, parent, id=None, logger=None): 283 | """id is optional to allow for easy subclass change of ROOT""" 284 | Widget.__init__(self, parent, logger=logger) 285 | self.id = id 286 | 287 | def read(self): 288 | return self.text 289 | 290 | @property 291 | def expandable(self): 292 | try: 293 | self.browser.element(self.EXPAND_LOCATOR) 294 | except NoSuchElementException: 295 | return False 296 | else: 297 | return True 298 | 299 | @property 300 | def expanded(self): 301 | if not self.expandable: 302 | return False 303 | return "open" in self.browser.classes(self) 304 | 305 | @property 306 | def collapsed(self): 307 | return not self.expanded 308 | 309 | def expand(self): 310 | if not self.expandable: 311 | raise ValueError(f"{self.locator} is not expandable") 312 | if not self.expanded: 313 | self.click() 314 | if not self.expanded: 315 | raise Exception(f"Could not expand {self.locator}") 316 | else: 317 | self.logger.info("expanded") 318 | 319 | def collapse(self): 320 | if not self.expandable: 321 | return 322 | if self.expanded: 323 | self.click() 324 | if self.expanded: 325 | raise Exception(f"Could not collapse {self.locator}") 326 | else: 327 | self.logger.info("collapsed") 328 | 329 | @property 330 | def text(self): 331 | try: 332 | el = self.browser.element(self.TEXT_LOCATOR) 333 | return self.browser.text(el, parent=self) 334 | except NoSuchElementException: 335 | return None 336 | 337 | @property 338 | def icon(self): 339 | try: 340 | el = self.browser.element('./a/span[contains(@class, "pficon")]', parent=self) 341 | for class_ in self.browser.classes(el): 342 | if class_.startswith("pficon-"): 343 | return class_[7:] 344 | else: 345 | return None 346 | except NoSuchElementException: 347 | return None 348 | 349 | @property 350 | def items(self): 351 | return [ 352 | self.browser.text(element) 353 | for element in self.browser.elements( 354 | './ul/li[not(contains(@class, "divider"))]', parent=self 355 | ) 356 | ] 357 | 358 | def has_item(self, item): 359 | return item in self.items 360 | 361 | def item_enabled(self, item): 362 | if not self.has_item(item): 363 | raise ValueError(f"There is not such item {item}") 364 | element = self.browser.element(f"./ul/li[normalize-space(.)={quote(item)}]", parent=self) 365 | return "disabled" not in self.browser.classes(element) 366 | 367 | def select_item(self, item): 368 | if not self.item_enabled(item): 369 | raise ValueError(f"Cannot click disabled item {item}") 370 | 371 | self.expand() 372 | self.logger.info(f"selecting item {item}") 373 | self.browser.click(f"./ul/li[normalize-space(.)={quote(item)}]", parent=self) 374 | 375 | def __repr__(self): 376 | return f"{type(self).__name__}(id={self.id!r})" 377 | 378 | 379 | class BootstrapNav(Widget): 380 | """Encapsulate a Bootstrap nav component 381 | 382 | PatternFly is based on Bootstrap, and thus many of the Bootstrap components are available to 383 | PatternFly users. This widget provides convenience methods for the Bootstrap nav component for 384 | clicking on links and determining if an item in the nav is disabled. 385 | 386 | When instantiating this widget, use the XPath locator to point to exactly which Bootstrap nav 387 | you wish to work with. 388 | 389 | .. _code:: python 390 | 391 | nav = BootstrapNav('//div[id="main"]/ul[@contains(@class, "nav")]') 392 | 393 | See http://getbootstrap.com/components/#nav for more information on Bootstrap nav components. 394 | """ 395 | 396 | ROOT = ParametrizedLocator("{@locator}") 397 | ITEM_LOCATOR = ".//li" 398 | CURRENTLY_SELECTED = './/li[contains(@class, "active")]/a' 399 | TEXT_MATCHING = ".//li/a[text()={txt}]" 400 | PARTIAL_TEXT = ".//li/a[contains(normalize-space(.), {txt})]" 401 | ATTR_MATCHING = ".//li/a[@{attr}={txt}]" 402 | TEXT_DISABLED = './/li[contains(@class, "disabled")]/a[text()={txt}]' 403 | PARTIAL_TEXT_DISABLED = ( 404 | './/li[contains(@class, "disabled")]/a[contains(normalize-space(.), {txt})]' 405 | ) 406 | ATTR_DISABLED = './/li[contains(@class, "disabled")]/a[@{attr}={txt}]' 407 | VALID_ATTRS = {"href", "title", "class", "id"} 408 | 409 | def __init__(self, parent, locator, logger=None): 410 | """Create the widget""" 411 | Widget.__init__(self, parent, logger=logger) 412 | self.locator = locator 413 | 414 | def __repr__(self): 415 | """String representation of this object""" 416 | return f"{type(self).__name__}({self.locator!r})" 417 | 418 | @property 419 | def currently_selected(self): 420 | """A property to return the currently selected menu item""" 421 | return [self.browser.text(el) for el in self.browser.elements(self.CURRENTLY_SELECTED)] 422 | 423 | @property 424 | def all_options(self): 425 | """A property to return the list of options available in the BootstrapNav""" 426 | b = self.browser 427 | return [b.text(el) for el in b.elements(self.ITEM_LOCATOR)] 428 | 429 | def read(self): 430 | """Implement read()""" 431 | return self.currently_selected 432 | 433 | def select(self, text=None, **kwargs): 434 | """ 435 | Select/click an item from the menu 436 | 437 | Args: 438 | text: text of the link to be selected, If you want to partial text match, 439 | use the :py:class:`BootstrapNav.partial` to wrap the value. 440 | """ 441 | if text: 442 | # Select an item based on the text of that item 443 | if isinstance(text, partial_match): 444 | text = text.item 445 | link = self.browser.element(self.PARTIAL_TEXT.format(txt=quote(text)), parent=self) 446 | self.logger.info("selecting by partial matching text: %r", text) 447 | else: 448 | link = self.browser.element(self.TEXT_MATCHING.format(txt=quote(text)), parent=self) 449 | self.logger.info("selecting by full matching text: %r", text) 450 | elif self.VALID_ATTRS & set(kwargs.keys()): 451 | # Select an item based on an attribute, if it is one of the VALID_ATTRS 452 | attr = (self.VALID_ATTRS & set(kwargs.keys())).pop() 453 | link = self.browser.element( 454 | self.ATTR_MATCHING.format(attr=attr, txt=quote(kwargs[attr])) 455 | ) 456 | else: 457 | # If neither text, nor one of the VALID_ATTRS is supplied, raise a KeyError 458 | raise KeyError(f"Either text or one of {self.VALID_ATTRS} needs to be specified") 459 | self.browser.click(link) 460 | 461 | def is_disabled(self, text=None, **kwargs): 462 | """Check if an item is disabled""" 463 | if text: 464 | # Check if an item is disabled based on the text of that item 465 | if isinstance(text, partial_match): 466 | partial_text = text.item 467 | xpath = self.PARTIAL_TEXT_DISABLED.format(txt=quote(partial_text)) 468 | else: 469 | xpath = self.TEXT_DISABLED.format(txt=quote(text)) 470 | elif self.VALID_ATTRS & set(kwargs.keys()): 471 | # Check if an item is disabled based on an attribute, if it is one of the VALID_ATTRS 472 | attr = (self.VALID_ATTRS & set(kwargs.keys())).pop() 473 | xpath = self.ATTR_DISABLED.format(attr=attr, txt=quote(kwargs[attr])) 474 | else: 475 | # If neither text, nor one of the VALID_ATTRS is supplied, raise a KeyError 476 | raise KeyError(f"Either text or one of {self.VALID_ATTRS} needs to be specified") 477 | try: 478 | self.browser.element(xpath, parent=self) 479 | return True 480 | except NoSuchElementException: 481 | return False 482 | 483 | def has_item(self, text=None, **kwargs): 484 | """Check if an item with this name or attributes exists""" 485 | if text: 486 | # Check if an item exists based on the text of that item 487 | xpath = self.TEXT_MATCHING.format(txt=quote(text)) 488 | elif self.VALID_ATTRS & set(kwargs.keys()): 489 | # Check if an item exists based on an attribute, if it is one of the VALID_ATTRS 490 | attr = (self.VALID_ATTRS & set(kwargs.keys())).pop() 491 | xpath = self.ATTR_MATCHING.format(attr=attr, txt=quote(kwargs[attr])) 492 | else: 493 | # If neither text, nor one of the VALID_ATTRS is supplied, raise a KeyError 494 | raise KeyError(f"Either text or one of {self.VALID_ATTRS} needs to be specified") 495 | try: 496 | self.browser.element(xpath, parent=self) 497 | return True 498 | except NoSuchElementException: 499 | return False 500 | 501 | 502 | class VerticalNavigation(Widget): 503 | """The Patternfly Vertical navigation.""" 504 | 505 | CURRENTLY_SELECTED = './/li[contains(@class, "active")]/a' 506 | LINKS = "./li/a" 507 | ITEMS_MATCHING = "./li[a[normalize-space(.)={}]]" 508 | DIV_LINKS_MATCHING = "./ul/li/a[span[normalize-space(.)={txt}] or @href={txt}]" 509 | SUB_LEVEL = './following-sibling::div[contains(@class, "nav-pf-")]' 510 | SUB_ITEM_LIST = './div[contains(@class, "nav-pf-")]/ul' 511 | CHILD_UL_FOR_DIV = './li[a[normalize-space(.)={}]]/div[contains(@class, "nav-pf-")]/ul' 512 | MATCHING_LI_FOR_DIV = "./ul/li[a[span[normalize-space(.)={}]]]" 513 | 514 | def __init__(self, parent, locator, logger=None): 515 | Widget.__init__(self, parent, logger=logger) 516 | self.locator = locator 517 | 518 | def __locator__(self): 519 | return self.locator 520 | 521 | def read(self): 522 | return self.currently_selected 523 | 524 | def nav_links(self, *levels): 525 | if not levels: 526 | return [self.browser.text(el) for el in self.browser.elements(self.LINKS, parent=self)] 527 | # Otherwise 528 | current_item = self 529 | for i, level in enumerate(levels): 530 | li = self.browser.element(self.ITEMS_MATCHING.format(quote(level)), parent=current_item) 531 | 532 | try: 533 | current_item = self.browser.element(self.SUB_ITEM_LIST, parent=li) 534 | except NoSuchElementException: 535 | if i == len(levels) - 1: 536 | # It is the last one 537 | return [] 538 | else: 539 | raise 540 | 541 | return [ 542 | self.browser.text(el) for el in self.browser.elements(self.LINKS, parent=current_item) 543 | ] 544 | 545 | def nav_item_tree(self, start=None): 546 | start = start or [] 547 | result = {} 548 | for item in self.nav_links(*start): 549 | sub_items = self.nav_item_tree(start=start + [item]) 550 | result[item] = sub_items or None 551 | if result and all(value is None for value in result.values()): 552 | # If there are no child nodes, then just make it a list 553 | result = list(result) # list of keys 554 | return result 555 | 556 | @property 557 | def currently_selected(self): 558 | return [ 559 | self.browser.text(el) 560 | for el in self.browser.elements(self.CURRENTLY_SELECTED, parent=self) 561 | ] 562 | 563 | def select(self, *levels, handle_alert=True, anyway=True): 564 | """Select an item in the navigation. 565 | Args: 566 | *levels: Items to be clicked in the navigation. 567 | Keywords: 568 | handle_alert(bool): If set to True, will call self.browser.handle_alert to handle 569 | alert popups. 570 | anyway(bool): Default behaviour is that if you try selecting an already selected item, 571 | it will click it anyway. If you pass ``anyway=False``, it won't click it. 572 | """ 573 | levels = list(levels) 574 | self.logger.info("Selecting %r in navigation", levels) 575 | if levels == self.currently_selected and not anyway: 576 | return 577 | 578 | passed_levels = [] 579 | current_div = self.get_child_div_for(*passed_levels) 580 | for level in levels: 581 | passed_levels.append(level) 582 | finished = passed_levels == levels 583 | link = self.browser.element( 584 | self.DIV_LINKS_MATCHING.format(txt=quote(level)), parent=current_div 585 | ) 586 | expands = bool(self.browser.elements(self.SUB_LEVEL, parent=link)) 587 | if expands and not finished: 588 | self.logger.debug("moving to %s to open the next level", level) 589 | # No safety check because previous command did it 590 | self.browser.move_to_element(link, check_safe=False) 591 | 592 | @wait_for_decorator(timeout="10s", delay=0.2) 593 | def other_div_displayed(): 594 | return "is-hover" in self.browser.classes( 595 | self.MATCHING_LI_FOR_DIV.format(quote(level)), parent=current_div 596 | ) 597 | 598 | new_div = self.get_child_div_for(*passed_levels) 599 | # No safety check because previous command did it 600 | self.browser.move_to_element(new_div, check_safe=False) 601 | current_div = new_div 602 | elif not expands and not finished: 603 | raise ValueError( 604 | f"You are trying to expand {passed_levels!r} which cannot be expanded" 605 | ) 606 | else: 607 | # finished 608 | self.logger.debug("finishing the menu selection by clicking on %s", level) 609 | # No safety check because previous command did it 610 | self.browser.click(link, ignore_ajax=True, check_safe=False) 611 | if handle_alert: 612 | self.browser.handle_alert(wait=2.0, squash=True) 613 | 614 | def get_child_div_for(self, *levels): 615 | current = self 616 | for level in levels: 617 | try: 618 | current = self.browser.element( 619 | self.CHILD_UL_FOR_DIV.format(quote(level)), parent=current 620 | ) 621 | except NoSuchElementException: 622 | return None 623 | 624 | return self.browser.element("..", parent=current) 625 | 626 | def __repr__(self): 627 | return f"{type(self).__name__}({self.locator!r})" 628 | 629 | 630 | class Tab(View): 631 | """Represents the Tab widget. 632 | 633 | Selects itself automatically when any child widget gets accessed, ensuring that the widget is 634 | visible. 635 | 636 | You can specify your own ``ROOT`` attribute on the class. 637 | """ 638 | 639 | #: The text on the tab. If it is the same as the tab class name capitalized, can be omitted 640 | TAB_NAME = None 641 | 642 | #: Locator of the Tab selector 643 | TAB_LOCATOR = ParametrizedLocator( 644 | './/ul[contains(@class, "nav-tabs")]/li[./a[normalize-space(.)={@tab_name|quote}]]' 645 | ) 646 | 647 | @property 648 | def tab_name(self): 649 | return self.TAB_NAME or type(self).__name__.capitalize() 650 | 651 | def is_active(self): 652 | return "active" in self.parent_browser.classes(self.TAB_LOCATOR) 653 | 654 | def is_disabled(self): 655 | return "disabled" in self.parent_browser.classes(self.TAB_LOCATOR) 656 | 657 | @property 658 | def is_displayed(self): 659 | return self.parent_browser.is_displayed(self.TAB_LOCATOR) 660 | 661 | def click(self): 662 | return self.parent_browser.click(self.TAB_LOCATOR) 663 | 664 | def select(self): 665 | if not self.is_active(): 666 | if self.is_disabled(): 667 | raise ValueError(f"The tab {self.tab_name} you are trying to select is disabled") 668 | self.logger.info("opened the tab %s", self.tab_name) 669 | self.click() 670 | 671 | def child_widget_accessed(self, widget): 672 | # Select the tab 673 | self.select() 674 | 675 | def __repr__(self): 676 | return f"" 677 | 678 | 679 | class GenericTabWithDropdown(Tab): 680 | """Tab with a dropdown. Variant that always takes the sub item name in the select(). 681 | 682 | Does not support automatic reveal of the tab upon access as the default dropdown item is not 683 | specified. 684 | """ 685 | 686 | def is_dropdown(self): 687 | return "dropdown" in self.parent_browser.classes(self.TAB_LOCATOR) 688 | 689 | def is_open(self): 690 | return "open" in self.parent_browser.classes(self.TAB_LOCATOR) 691 | 692 | def open(self): 693 | if not self.is_open(): 694 | self.logger.info("opened the tab %s", self.tab_name) 695 | self.click() 696 | 697 | def close(self): 698 | if self.is_open(): 699 | self.logger.info("closed the tab %s", self.tab_name) 700 | self.click() 701 | 702 | def select(self, sub_item): 703 | if not self.is_dropdown(): 704 | raise TypeError("{} is not a tab with dropdown and CHECK_IF_DROPDOWN is True") 705 | self.open() 706 | parent = self.parent_browser.element(self.TAB_LOCATOR) 707 | self.logger.info("clicking the sub-item %r", sub_item) 708 | self.parent_browser.click(f"./ul/li[normalize-space(.)={quote(sub_item)}]", parent=parent) 709 | 710 | def child_widget_accessed(self, widget): 711 | """Nothing. Since we don't know which sub_item.""" 712 | 713 | def __repr__(self): 714 | return f"" 715 | 716 | 717 | class TabWithDropdown(GenericTabWithDropdown): 718 | """Tab with the dropdown and its selection item set so child_widget_accessed work as usual.""" 719 | 720 | #: Specify the dropdown item here 721 | SUB_ITEM = None 722 | 723 | def select(self): 724 | return super().select(self.SUB_ITEM) 725 | 726 | def child_widget_accessed(self, widget): 727 | # Redefine it back like in Tab since TabWithDropdown removes it 728 | self.select() 729 | 730 | def __repr__(self): 731 | return f"" 732 | 733 | 734 | class Accordion(View, ClickableMixin): 735 | """Bootstrap/Patternfly accordions. 736 | 737 | They are like views that contain widgets. If a widget is accessed in the accordion, the 738 | accordion makes sure that it is open. 739 | 740 | You need to set the ``ACCORDION_NAME`` to correspond with the text in the accordion. 741 | If the accordion title is just a capitalized version of the accordion class name, you do not 742 | need to set the ``ACCORDION_NAME``. 743 | 744 | If the accordion is in an exotic location, you also have to change the ``ROOT``. 745 | 746 | Accordions can contain trees. Basic ``TREE_LOCATOR`` is tuned after ManageIQ so if your UI has a 747 | different structure, you should change this locator accordingly. 748 | """ 749 | 750 | ACCORDION_NAME = None 751 | ROOT = ParametrizedLocator( 752 | './/div[contains(@class, "panel-group")]/div[contains(@class, "panel") and ' 753 | "./div/h4/a[normalize-space(.)={@accordion_name|quote}]]" 754 | ) 755 | TREE_LOCATOR = "|".join( 756 | [ 757 | ".//miq-tree-view", 758 | './/div[contains(@class, "treeview") and ./ul]', 759 | './/div[./ul[contains(@class, "dynatree-container")]]', 760 | ] 761 | ) 762 | HEADER_LOCATOR = "./div/h4/a" 763 | 764 | @property 765 | def accordion_name(self): 766 | return self.ACCORDION_NAME or type(self).__name__.capitalize() 767 | 768 | @property 769 | def is_opened(self): 770 | attr = self.browser.get_attribute("aria-expanded", self) 771 | if attr is None: 772 | # Try other way 773 | panel = self.browser.element('./div[contains(@class, "panel-collapse")]') 774 | classes = self.browser.classes(panel) 775 | return "collapse" in classes and "in" in classes 776 | else: 777 | return attr.lower().strip() == "true" 778 | 779 | @property 780 | def is_closed(self): 781 | return not self.is_opened 782 | 783 | def click(self): 784 | """Override Clickable's click.""" 785 | self.browser.click(self.HEADER_LOCATOR) 786 | 787 | def open(self): 788 | if self.is_closed: 789 | self.logger.info("opening") 790 | self.click() 791 | try: 792 | wait_for(lambda: self.is_opened, delay=0.1, num_sec=3) 793 | except TimedOutError: 794 | self.logger.warning("Could not open the accordion, trying clicking again") 795 | # Workaround stupid pages, perhaps we put a try mechanism in here 796 | if self.is_closed: 797 | self.click() 798 | try: 799 | wait_for(lambda: self.is_opened, delay=0.1, num_sec=3) 800 | except TimedOutError: 801 | self.logger.error("Could not open the accordion") 802 | raise Exception(f"Could not open accordion {self.accordion_name}") 803 | 804 | def close(self): 805 | if self.is_opened: 806 | self.logger.info("closing") 807 | self.click() 808 | 809 | def child_widget_accessed(self, widget): 810 | # Open the Accordion 811 | self.open() 812 | 813 | def read(self): 814 | if self.is_closed: 815 | do_not_read_this_widget() 816 | return super().read() 817 | 818 | @cached_property 819 | def tree_id(self): 820 | try: 821 | el = self.browser.element(self.TREE_LOCATOR) 822 | except NoSuchElementException: 823 | raise AttributeError(f"No tree in the accordion {self.accordion_name}") 824 | else: 825 | return self.browser.get_attribute("id", el) or self.browser.get_attribute("name", el) 826 | 827 | def __repr__(self): 828 | return f"" 829 | 830 | 831 | class BootstrapSelect(Widget, ClickableMixin): 832 | """This class represents the Bootstrap Select widget. 833 | 834 | Args: 835 | id: id of the select, that is the ``data-id`` attribute on the ``button`` tag. 836 | name: name of the select tag 837 | locator: If none of above apply, you can also supply a full locator. 838 | can_hide_on_select: Whether the select can hide after selection, important for 839 | :py:meth:`close` to work properly. 840 | """ 841 | 842 | Option = namedtuple("Option", ["text", "value"]) 843 | LOCATOR_START = './/div[contains(@class, "bootstrap-select")]' 844 | ROOT = ParametrizedLocator("{@locator}") 845 | BY_VISIBLE_TEXT = '//div/ul/li/a[./span[contains(@class, "text") and normalize-space(.)={}]]' 846 | BY_PARTIAL_VISIBLE_TEXT = ( 847 | '//div/ul/li/a[./span[contains(@class, "text") and contains(normalize-space(.), {})]]' 848 | ) 849 | 850 | def __init__( 851 | self, parent, id=None, name=None, locator=None, can_hide_on_select=False, logger=None 852 | ): 853 | Widget.__init__(self, parent, logger=logger) 854 | if id is not None: 855 | self.locator = self.LOCATOR_START + "/button[normalize-space(@data-id)={}]/..".format( 856 | quote(id) 857 | ) 858 | elif name is not None: 859 | self.locator = self.LOCATOR_START + "/select[normalize-space(@name)={}]/..".format( 860 | quote(name) 861 | ) 862 | elif locator is not None: 863 | self.locator = locator 864 | else: 865 | raise TypeError("You need to specify either, id, name or locator for BootstrapSelect") 866 | self.id = id 867 | self.can_hide_on_select = can_hide_on_select 868 | 869 | @property 870 | def is_open(self): 871 | try: 872 | return "open" in self.browser.classes(self) 873 | except StaleElementReferenceException: 874 | self.logger.warning( 875 | "Got a StaleElementReferenceException in .is_open, but ignoring. Returned False." 876 | ) 877 | return False 878 | 879 | @property 880 | def is_multiple(self): 881 | return "show-tick" in self.browser.classes(self) 882 | 883 | def open(self): 884 | if not self.is_open: 885 | self.click() 886 | self.logger.debug("opened") 887 | 888 | def close(self): 889 | try: 890 | if self.is_open: 891 | self.click() 892 | self.logger.debug("closed") 893 | except NoSuchElementException: 894 | if self.can_hide_on_select: 895 | self.logger.info("While closing %r it disappeared, but ignoring.", self) 896 | else: 897 | raise 898 | 899 | def select_by_visible_text(self, *items): 900 | """Selects items in the select. 901 | 902 | Args: 903 | *items: Items to be selected. If the select does not support multiple selections and you 904 | pass more than one item, it will raise an exception. If you want to select using 905 | partial match, use the :py:class:`BootstrapSelect.partial` to wrap the value. 906 | """ 907 | if len(items) > 1 and not self.is_multiple: 908 | raise ValueError( 909 | f"The BootstrapSelect {self.locator} does not allow multiple selections" 910 | ) 911 | self.open() 912 | for item in items: 913 | if isinstance(item, partial_match): 914 | item = item.item 915 | self.logger.info("selecting by partial visible text: %r", item) 916 | try: 917 | self.browser.click( 918 | self.BY_PARTIAL_VISIBLE_TEXT.format(quote(item)), 919 | parent=self, 920 | force_scroll=True, 921 | ) 922 | except NoSuchElementException: 923 | try: 924 | # Added this as for some views(some tags pages) dropdown is separated from 925 | # button and doesn't have exact id or name 926 | self.browser.click( 927 | self.BY_PARTIAL_VISIBLE_TEXT.format(quote(item)), force_scroll=True 928 | ) 929 | except NoSuchElementException: 930 | raise SelectItemNotFound( 931 | widget=self, item=item, options=[opt.text for opt in self.all_options] 932 | ) 933 | else: 934 | self.logger.info("selecting by visible text: %r", item) 935 | try: 936 | self.browser.click( 937 | self.BY_VISIBLE_TEXT.format(quote(item)), parent=self, force_scroll=True 938 | ) 939 | except NoSuchElementException: 940 | try: 941 | # Added this as for some views(some tags pages) dropdown is separated from 942 | # button and doesn't have exact id or name 943 | self.browser.click( 944 | self.BY_VISIBLE_TEXT.format(quote(item)), force_scroll=True 945 | ) 946 | except NoSuchElementException: 947 | raise SelectItemNotFound( 948 | widget=self, item=item, options=[opt.text for opt in self.all_options] 949 | ) 950 | self.close() 951 | 952 | @property 953 | def all_selected_options(self): 954 | return [ 955 | self.browser.text(e) 956 | for e in self.browser.elements( 957 | './div/ul/li[contains(@class, "selected")]/a/span[contains(@class, "text")]', 958 | parent=self, 959 | ) 960 | ] 961 | 962 | @property 963 | def all_options(self): 964 | b = self.browser 965 | return [ 966 | self.Option( 967 | b.text(b.element('.//span[contains(@class, "text")]', parent=e)), 968 | e.get_attribute("data-original-index"), 969 | ) 970 | for e in b.elements("./div/ul/li", parent=self) 971 | ] 972 | 973 | @property 974 | def selected_option(self): 975 | return self.all_selected_options[0] 976 | 977 | def read(self): 978 | if self.is_multiple: 979 | return self.all_selected_options 980 | else: 981 | return self.selected_option 982 | 983 | def fill(self, items): 984 | if not isinstance(items, (list, tuple, set)): 985 | items = {items} 986 | elif not isinstance(items, set): 987 | items = set(items) 988 | 989 | if set(self.all_selected_options) == items: 990 | return False 991 | else: 992 | self.select_by_visible_text(*items) 993 | return True 994 | 995 | def __repr__(self): 996 | return f"{type(self).__name__}(locator={self.locator!r})" 997 | 998 | 999 | class BootstrapTreeview(Widget): 1000 | """A class representing the Bootstrap treeview used in newer builds. 1001 | 1002 | Implements ``expand_path``, ``click_path``, ``read_contents``. All are implemented in manner 1003 | very similar to the original :py:class:`Tree`. 1004 | 1005 | You don't have to specify the ``tree_id`` if the hosting object implements ``tree_id``. 1006 | 1007 | Args: 1008 | tree_id: Id of the tree, the closest div or ``miq-tree-view`` to the root ``ul`` element. 1009 | """ 1010 | 1011 | ROOT = ParametrizedLocator( 1012 | "|".join([".//miq-tree-view[@name={@tree_id|quote}]/div", ".//div[@id={@tree_id|quote}]"]) 1013 | ) 1014 | ROOT_ITEM = "./ul/li[1]" 1015 | ROOT_ITEMS = './ul/li[not(./span[contains(@class, "indent")])]' 1016 | ROOT_ITEMS_WITH_TEXT = ( 1017 | './ul/li[not(./span[contains(@class, "indent")]) and contains(normalize-space(.), {text})]' 1018 | ) 1019 | SELECTED_ITEM = './ul/li[contains(@class, "node-selected")]' 1020 | CHILD_ITEMS = ( 1021 | "./ul/li[starts-with(@data-nodeid, {id}) and not(@data-nodeid={id})" 1022 | ' and count(./span[contains(@class, "indent")])={indent}]' 1023 | ) 1024 | CHILD_ITEMS_TEXT = ( 1025 | "./ul/li[starts-with(@data-nodeid, {id}) and not(@data-nodeid={id})" 1026 | " and (contains(@title, {text}) or contains(normalize-space(.), {text}))" 1027 | ' and count(./span[contains(@class, "indent")])={indent}]' 1028 | ) 1029 | ITEM_BY_NODEID = "./ul/li[@data-nodeid={}]" 1030 | IS_EXPANDABLE = './span[contains(@class, "expand-icon")]' 1031 | IS_EXPANDED = './span[contains(@class, "expand-icon") and contains(@class, "fa-angle-down")]' 1032 | IS_LOADING = './span[contains(@class, "expand-icon") and contains(@class, "fa-spinner")]' 1033 | INDENT = './span[contains(@class, "indent")]' 1034 | 1035 | def __init__(self, parent, tree_id=None, logger=None): 1036 | Widget.__init__(self, parent, logger=logger) 1037 | self._tree_id = tree_id 1038 | 1039 | @property 1040 | def tree_id(self): 1041 | """If you did not specify the tree_id when creating the tree, it will try to pull it out of 1042 | the parent object. 1043 | 1044 | This is useful if some kinds of objects contain trees regularly, then the definition gets 1045 | simpler and the tree id is not neded to be specified. 1046 | """ 1047 | if self._tree_id is not None: 1048 | return self._tree_id 1049 | else: 1050 | try: 1051 | return self.parent.tree_id 1052 | except AttributeError: 1053 | raise NameError( 1054 | "You have to specify tree_id to BootstrapTreeview if the parent object does " 1055 | "not implement .tree_id!" 1056 | ) 1057 | 1058 | def image_getter(self, item): 1059 | """Look up the image that is hidden in the style tag or as a tag. 1060 | 1061 | Returns: 1062 | The name of the image without the hash, path and extension. 1063 | """ 1064 | try: 1065 | image_node = self.browser.element( 1066 | './span[contains(@class, "node-image") or contains(@class, "node-icon")]', 1067 | parent=item, 1068 | ) 1069 | except NoSuchElementException: 1070 | self.logger.warning("No image tag found") 1071 | return None 1072 | style = self.browser.get_attribute("style", image_node) 1073 | if style: 1074 | image_href = re.search(r'url\("([^"]+)"\)', style).groups()[0] 1075 | try: 1076 | return re.search(r"/([^/]+)-[0-9a-f]+\.(?:png|svg)$", image_href).groups()[0] 1077 | except AttributeError: 1078 | return None 1079 | else: 1080 | classes = self.browser.classes(image_node) 1081 | try: 1082 | return [ 1083 | c for c in classes if c.startswith(("fa-", "product-", "vendor-", "pficon-")) 1084 | ][0] 1085 | except IndexError: 1086 | return None 1087 | 1088 | def read(self): 1089 | return self.currently_selected 1090 | 1091 | def fill(self, value): 1092 | if self.currently_selected == value: 1093 | return False 1094 | self.click_path(*value) 1095 | return True 1096 | 1097 | @property 1098 | def currently_selected(self): 1099 | if self.selected_item is not None: 1100 | nodeid = self.get_nodeid(self.selected_item).split(".") 1101 | root_id_len = len(self.get_nodeid(self.root_item).split(".")) 1102 | result = [] 1103 | for end in range(root_id_len, len(nodeid) + 1): 1104 | current_nodeid = ".".join(nodeid[:end]) 1105 | text = self.browser.text(self.get_item_by_nodeid(current_nodeid)) 1106 | result.append(text) 1107 | return result 1108 | else: 1109 | return None 1110 | 1111 | @property 1112 | def root_item_count(self): 1113 | return len(self.root_items) 1114 | 1115 | @property 1116 | def root_items(self): 1117 | return self.browser.elements(self.ROOT_ITEMS, parent=self) 1118 | 1119 | @property 1120 | def root_item(self): 1121 | if self.root_item_count == 1: 1122 | return self.browser.element(self.ROOT_ITEM, parent=self) 1123 | else: 1124 | return None 1125 | 1126 | @property 1127 | def selected_item(self): 1128 | try: 1129 | result = self.browser.element(self.SELECTED_ITEM, parent=self) 1130 | except NoSuchElementException: 1131 | result = None 1132 | return result 1133 | 1134 | def indents(self, item): 1135 | return len(self.browser.elements(self.INDENT, parent=item)) 1136 | 1137 | def is_expandable(self, item): 1138 | return bool(self.browser.elements(self.IS_EXPANDABLE, parent=item)) 1139 | 1140 | def is_expanded(self, item): 1141 | return bool(self.browser.elements(self.IS_EXPANDED, parent=item)) 1142 | 1143 | def is_loading(self, item): 1144 | return bool(self.browser.elements(self.IS_LOADING, parent=item)) 1145 | 1146 | def is_collapsed(self, item): 1147 | return not self.is_expanded(item) 1148 | 1149 | def is_selected(self, item): 1150 | return "node-selected" in self.browser.classes(item) 1151 | 1152 | def get_nodeid(self, item): 1153 | return self.browser.get_attribute("data-nodeid", item) 1154 | 1155 | def get_expand_arrow(self, item): 1156 | return self.browser.element(self.IS_EXPANDABLE, parent=item) 1157 | 1158 | def child_items(self, item): 1159 | """Returns all child items of given item. 1160 | 1161 | Args: 1162 | item: WebElement of the node. 1163 | 1164 | Returns: 1165 | List of *all* child items of the item. 1166 | """ 1167 | if item is not None: 1168 | nodeid = quote(self.get_nodeid(item)) 1169 | node_indents = self.indents(item) 1170 | return self.browser.elements( 1171 | self.CHILD_ITEMS.format(id=nodeid, indent=node_indents + 1), parent=self 1172 | ) 1173 | else: 1174 | return self.browser.elements(self.ROOT_ITEMS, parent=self) 1175 | 1176 | def child_items_with_text(self, item, text): 1177 | """Returns all child items of given item that contain the given text. 1178 | 1179 | Args: 1180 | item: WebElement of the node. 1181 | text: Text to be matched 1182 | 1183 | Returns: 1184 | List of all child items of the item *that contain the given text*. 1185 | """ 1186 | 1187 | text = quote(text) 1188 | if item is not None: 1189 | nodeid = quote(self.get_nodeid(item)) 1190 | node_indents = self.indents(item) 1191 | return self.browser.elements( 1192 | self.CHILD_ITEMS_TEXT.format(id=nodeid, text=text, indent=node_indents + 1), 1193 | parent=self, 1194 | ) 1195 | else: 1196 | return self.browser.elements(self.ROOT_ITEMS_WITH_TEXT.format(text=text), parent=self) 1197 | 1198 | def get_item_by_nodeid(self, nodeid): 1199 | nodeid_q = quote(nodeid) 1200 | try: 1201 | return self.browser.element(self.ITEM_BY_NODEID.format(nodeid_q), parent=self) 1202 | except NoSuchElementException: 1203 | raise CandidateNotFound( 1204 | { 1205 | "message": "Could not find the item with nodeid {} in Bootstrap tree {}".format( 1206 | nodeid, self.tree_id 1207 | ), 1208 | "path": "", 1209 | "cause": "", 1210 | } 1211 | ) 1212 | 1213 | def expand_node(self, nodeid): 1214 | """Expands a node given its nodeid. Must be visible 1215 | 1216 | Args: 1217 | nodeid: ``nodeId`` of the node 1218 | 1219 | Returns: 1220 | ``True`` if it was possible to expand the node, otherwise ``False``. 1221 | """ 1222 | node = self.get_item_by_nodeid(nodeid) 1223 | if not self.is_expandable(node): 1224 | self.logger.debug("Node %s not expandable on tree %s", nodeid, self.tree_id) 1225 | return False 1226 | if self.is_collapsed(node): 1227 | self.logger.debug("Expanding collapsed node %s on tree %s", nodeid, self.tree_id) 1228 | arrow = self.get_expand_arrow(node) 1229 | self.browser.click(arrow) 1230 | time.sleep(0.1) 1231 | wait_for( 1232 | lambda: not self.is_loading(self.get_item_by_nodeid(nodeid)), delay=0.2, num_sec=30 1233 | ) 1234 | wait_for( 1235 | lambda: self.is_expanded(self.get_item_by_nodeid(nodeid)), delay=0.2, num_sec=10 1236 | ) 1237 | else: 1238 | self.logger.debug("Node %s already expanded on tree %s", nodeid, self.tree_id) 1239 | return True 1240 | 1241 | def collapse_node(self, nodeid): 1242 | """Collapses a node given its nodeid. Must be visible 1243 | 1244 | Args: 1245 | nodeid: ``nodeId`` of the node 1246 | 1247 | Returns: 1248 | ``True`` if it was possible to expand the node, otherwise ``False``. 1249 | """ 1250 | node = self.get_item_by_nodeid(nodeid) 1251 | if not self.is_expandable(node): 1252 | self.logger.debug("Node %s not expandable on tree %s", nodeid, self.tree_id) 1253 | return False 1254 | if self.is_expanded(node): 1255 | self.logger.debug("Collapsing expanded node %s on tree %s", nodeid, self.tree_id) 1256 | arrow = self.get_expand_arrow(node) 1257 | self.browser.click(arrow) 1258 | time.sleep(0.1) 1259 | wait_for( 1260 | lambda: self.is_collapsed(self.get_item_by_nodeid(nodeid)), delay=0.2, num_sec=10 1261 | ) 1262 | else: 1263 | self.logger.debug("Node %s already collapsed on tree %s", nodeid, self.tree_id) 1264 | return True 1265 | 1266 | def _process_step(self, step): 1267 | """Steps can be plain strings or tuples when matching images""" 1268 | if isinstance(step, VersionPick): 1269 | # Version pick passed, coerce it ... 1270 | step = step.pick(self.browser.product_version) 1271 | 1272 | if isinstance(step, tuple): 1273 | image = step[0] 1274 | step = step[1] 1275 | if isinstance(step, VersionPick): 1276 | # Version pick passed, coerce it ... 1277 | step = step.pick(self.browser.product_version) 1278 | else: 1279 | image = None 1280 | if not isinstance(step, (str, Pattern)): 1281 | step = str(step) 1282 | return image, step 1283 | 1284 | @staticmethod 1285 | def _repr_step(image, step): 1286 | if isinstance(step, Pattern): 1287 | # Make it look like r'pattern' 1288 | step_repr = "r" + re.sub(r'^[^"\']', "", repr(step.pattern)) 1289 | else: 1290 | step_repr = step 1291 | if image is None: 1292 | return step_repr 1293 | else: 1294 | return f"{step_repr}[{image}]" 1295 | 1296 | def pretty_path(self, path): 1297 | return "/".join(self._repr_step(*self._process_step(step)) for step in path) 1298 | 1299 | def validate_node(self, node, matcher, image): 1300 | """Helper method that matches nodes by given conditions. 1301 | 1302 | Args: 1303 | node: Node that is matched 1304 | matcher: If it is an instance of regular expression, that one is used, otherwise 1305 | equality comparison is used. Against item name. 1306 | image: If not None, then after the matcher matches, this will do an additional check for 1307 | the image name 1308 | 1309 | Returns: 1310 | A :py:class:`bool` if the node is correct or not. 1311 | """ 1312 | text = self.browser.text(node) 1313 | if isinstance(matcher, Pattern): 1314 | match = matcher.match(text) is not None 1315 | else: 1316 | match = matcher == text 1317 | if not match: 1318 | return False 1319 | if image is not None and self.image_getter(node) != image: 1320 | return False 1321 | return True 1322 | 1323 | def expand_path(self, *path, **kwargs): 1324 | """Expands given path and returns the leaf node. 1325 | 1326 | The path items can be plain strings. In that case, exact string matching happens. Path items 1327 | can also be compiled regexps, where the ``match`` method is used to determine if the node 1328 | is the one we want. And finally, the path items can be 2-tuples, where the second item can 1329 | be the string or regular expression and the first item is the image to be matched using 1330 | :py:meth:`image_getter` method. 1331 | 1332 | Args: 1333 | *path: The path (explained above) 1334 | 1335 | Returns: 1336 | The leaf WebElement. 1337 | 1338 | Raises: 1339 | :py:class:`CandidateNotFound` when the node is not found in the tree. 1340 | """ 1341 | self.browser.plugin.ensure_page_safe() 1342 | self.logger.info("Expanding path %s on tree %s", self.pretty_path(path), self.tree_id) 1343 | node = self.root_item 1344 | if node is not None: 1345 | step = path[0] 1346 | steps_tried = [step] 1347 | image, step = self._process_step(step) 1348 | path = path[1:] 1349 | self.logger.debug("Validating presence of %r as the root item of the tree", step) 1350 | if not self.validate_node(node, step, image): 1351 | raise CandidateNotFound( 1352 | { 1353 | "message": "Could not find the item {} in Bootstrap tree {}".format( 1354 | self.pretty_path(steps_tried), self.tree_id 1355 | ), 1356 | "path": path, 1357 | "cause": f"Root node did not match {self._repr_step(image, step)}", 1358 | } 1359 | ) 1360 | else: 1361 | steps_tried = [] 1362 | for step in path: 1363 | steps_tried.append(step) 1364 | self.logger.debug("Expanding %r", steps_tried) 1365 | image, step = self._process_step(step) 1366 | if node is not None and not self.expand_node(self.get_nodeid(node)): 1367 | raise CandidateNotFound( 1368 | { 1369 | "message": "Could not find the item {} in Bootstrap tree {}".format( 1370 | self.pretty_path(steps_tried), self.tree_id 1371 | ), 1372 | "path": path, 1373 | "cause": "Could not expand the {} node".format( 1374 | self._repr_step(image, step) 1375 | ), 1376 | } 1377 | ) 1378 | if isinstance(step, str): 1379 | # To speed up the search when having a string to match, pick up items with that text 1380 | child_items = self.child_items_with_text(node, step) 1381 | else: 1382 | # Otherwise we need to go through all of them. 1383 | child_items = self.child_items(node) 1384 | for child_item in child_items: 1385 | if self.validate_node(child_item, step, image): 1386 | node = child_item 1387 | break 1388 | else: 1389 | raise CandidateNotFound( 1390 | { 1391 | "message": "Could not find the item {} in Bootstrap tree {}".format( 1392 | self.pretty_path(steps_tried), self.tree_id 1393 | ), 1394 | "path": path, 1395 | "cause": "Was not found in {}".format( 1396 | self._repr_step(*self._process_step(steps_tried[-2])) 1397 | ), 1398 | } 1399 | ) 1400 | 1401 | return node 1402 | 1403 | def click_path(self, *path, **kwargs): 1404 | """Expands the path and clicks the leaf node. 1405 | 1406 | See :py:meth:`expand_path` for more informations about synopsis. 1407 | """ 1408 | node = self.expand_path(*path, **kwargs) 1409 | self.logger.info("clicking node %r", path[-1]) 1410 | self.browser.click(node) 1411 | return node 1412 | 1413 | def has_path(self, *path, **kwargs): 1414 | """Determine if the path exists in the tree. 1415 | 1416 | See :py:meth:`expand_path` for more information about the arguments. 1417 | """ 1418 | try: 1419 | self.expand_path(*path, **kwargs) 1420 | return True 1421 | except CandidateNotFound: 1422 | return False 1423 | 1424 | def read_contents(self, nodeid=None, include_images=False, collapse_after_read=False): 1425 | """Reads the contents of the tree into a tree structure of strings and lists. 1426 | 1427 | This method is called recursively. 1428 | 1429 | Args: 1430 | nodeid: id of the node where the process should start from. 1431 | include_images: If True, the values will be tuples where first item will be the image 1432 | name and the second item the item name. If False then the values are just the item 1433 | names. 1434 | collapse_after_read: If True, then every branch that was read completely gets collapsed. 1435 | 1436 | Returns: 1437 | :py:class:`list` 1438 | """ 1439 | if nodeid is None and self.root_item is not None: 1440 | return self.read_contents( 1441 | nodeid=self.get_nodeid(self.root_item), 1442 | include_images=include_images, 1443 | collapse_after_read=collapse_after_read, 1444 | ) 1445 | 1446 | item = self.get_item_by_nodeid(nodeid) 1447 | self.expand_node(nodeid) 1448 | result = [] 1449 | 1450 | for child_item in self.child_items(item): 1451 | result.append( 1452 | self.read_contents( 1453 | nodeid=self.get_nodeid(child_item), 1454 | include_images=include_images, 1455 | collapse_after_read=collapse_after_read, 1456 | ) 1457 | ) 1458 | 1459 | if collapse_after_read: 1460 | self.collapse_node(nodeid) 1461 | 1462 | if include_images: 1463 | this_item = (self.image_getter(item), self.browser.text(item)) 1464 | else: 1465 | this_item = self.browser.text(item) 1466 | if result: 1467 | return [this_item, result] 1468 | else: 1469 | return this_item 1470 | 1471 | def __repr__(self): 1472 | return f"{type(self).__name__}({self.tree_id!r})" 1473 | 1474 | 1475 | class CheckableBootstrapTreeview(BootstrapTreeview): 1476 | """Checkable variation of CFME Tree. This widget not only expand a tree for a provided path, 1477 | but also checks a checkbox. 1478 | """ 1479 | 1480 | IS_CHECKABLE = './span[contains(@class, "check-icon")]' 1481 | IS_CHECKED = './span[contains(@class, "check-icon") and contains(@class, "fa-check-square-o")]' 1482 | 1483 | CheckNode = namedtuple("CheckNode", ["path"]) 1484 | UncheckNode = namedtuple("UncheckNode", ["path"]) 1485 | 1486 | def is_checkable(self, item): 1487 | return bool(self.browser.elements(self.IS_CHECKABLE, parent=item)) 1488 | 1489 | def is_checked(self, item): 1490 | return bool(self.browser.elements(self.IS_CHECKED, parent=item)) 1491 | 1492 | def check_uncheck_node(self, check, *path, **kwargs): 1493 | leaf = self.expand_path(*path, **kwargs) 1494 | if not self.is_checkable(leaf): 1495 | raise TypeError( 1496 | "Item with path {} in {} is not checkable".format( 1497 | self.pretty_path(path), self.tree_id 1498 | ) 1499 | ) 1500 | checked = self.is_checked(leaf) 1501 | if checked != check: 1502 | self.logger.info("%s %r", "Checking" if check else "Unchecking", path[-1]) 1503 | self.browser.click(self.IS_CHECKABLE, parent=leaf) 1504 | return True 1505 | else: 1506 | return False 1507 | 1508 | def check_node(self, *path, **kwargs): 1509 | """Expands the passed path and checks a checkbox that is located at the node.""" 1510 | return self.check_uncheck_node(True, *path, **kwargs) 1511 | 1512 | def uncheck_node(self, *path, **kwargs): 1513 | """Expands the passed path and unchecks a checkbox that is located at the node.""" 1514 | return self.check_uncheck_node(False, *path, **kwargs) 1515 | 1516 | def node_checked(self, *path, **kwargs): 1517 | """Check if a checkbox is checked on the node in that path.""" 1518 | leaf = self.expand_path(*path, **kwargs) 1519 | if not self.is_checkable(leaf): 1520 | return False 1521 | return self.is_checked(leaf) 1522 | 1523 | def fill(self, node): 1524 | """ 1525 | Args: 1526 | node: CheckNode/UncheckNode namedtuple with path 1527 | 1528 | Returns: 1529 | boolean for whether or not the path changed 1530 | """ 1531 | if not isinstance(node, (self.CheckNode, self.UncheckNode)): 1532 | raise ValueError("node in must be CheckNode or UncheckNode namedtuple") 1533 | return self.check_uncheck_node(isinstance(node, self.CheckNode), *node.path) 1534 | 1535 | def read(self): 1536 | do_not_read_this_widget() 1537 | 1538 | 1539 | class Dropdown(Widget): 1540 | """Represents the Patternfly/Bootstrap dropdown. 1541 | 1542 | Args: 1543 | text: Text of the button, can be the inner text or the title attribute. 1544 | """ 1545 | 1546 | ROOT = ParametrizedLocator( 1547 | './/div[contains(@class, "dropdown") and ./button[normalize-space(.)={@text|quote} or ' 1548 | "normalize-space(@title)={@text|quote}]]" 1549 | ) 1550 | BUTTON_LOCATOR = "./button" 1551 | ITEMS_LOCATOR = "./ul/li/a" 1552 | ITEM_LOCATOR = "./ul/li/a[normalize-space(.)={}]" 1553 | 1554 | def __init__(self, parent, text, logger=None): 1555 | Widget.__init__(self, parent, logger=logger) 1556 | self.text = text 1557 | 1558 | @property 1559 | def is_enabled(self): 1560 | """Returns if the toolbar itself is enabled and therefore interactive.""" 1561 | button = self.browser.element(self.BUTTON_LOCATOR, parent=self) 1562 | return "disabled" not in self.browser.classes(button) 1563 | 1564 | def _verify_enabled(self): 1565 | if not self.is_enabled: 1566 | raise DropdownDisabled(f'Dropdown "{self.text}" is not enabled') 1567 | 1568 | @property 1569 | def currently_selected(self): 1570 | """Returns the currently selected item text.""" 1571 | return self.browser.text(self.BUTTON_LOCATOR, parent=self) 1572 | 1573 | def read(self): 1574 | return self.currently_selected 1575 | 1576 | @property 1577 | def is_open(self): 1578 | return "open" in self.browser.classes(self) 1579 | 1580 | def open(self): 1581 | self._verify_enabled() 1582 | if not self.is_open: 1583 | self.browser.click(self) 1584 | 1585 | def close(self, ignore_nonpresent=False): 1586 | """Close the dropdown 1587 | 1588 | Args: 1589 | ignore_nonpresent: Will ignore exceptions due to disabled or missing dropdown 1590 | """ 1591 | try: 1592 | self._verify_enabled() 1593 | if self.is_open: 1594 | self.browser.click(self) 1595 | except (NoSuchElementException, DropdownDisabled): 1596 | if ignore_nonpresent: 1597 | self.logger.info("%r hid so it was not possible to close it. But ignoring.", self) 1598 | else: 1599 | raise 1600 | 1601 | @property 1602 | def items(self): 1603 | """Returns a list of all dropdown items as strings.""" 1604 | return [ 1605 | self.browser.text(el) for el in self.browser.elements(self.ITEMS_LOCATOR, parent=self) 1606 | ] 1607 | 1608 | def has_item(self, item): 1609 | """Returns whether the items exists. 1610 | 1611 | Args: 1612 | item: item name 1613 | 1614 | Returns: 1615 | Boolean - True if enabled, False if not. 1616 | """ 1617 | return item in self.items 1618 | 1619 | def item_element(self, item): 1620 | """Returns a WebElement for given item name.""" 1621 | try: 1622 | return self.browser.element(self.ITEM_LOCATOR.format(quote(item)), parent=self) 1623 | except NoSuchElementException: 1624 | try: 1625 | items = self.items 1626 | except NoSuchElementException: 1627 | items = [] 1628 | if items: 1629 | items_string = "These items are present: {}".format("; ".join(items)) 1630 | else: 1631 | items_string = "The dropdown is probably not present" 1632 | raise DropdownItemNotFound(f"Item {item!r} not found. {items_string}") 1633 | 1634 | def item_title(self, item): 1635 | el = self.item_element(item) 1636 | li = self.browser.element("./a", parent=el) 1637 | return self.browser.get_attribute("title", li) 1638 | 1639 | def item_enabled(self, item): 1640 | """Returns whether the given item is enabled. 1641 | 1642 | Args: 1643 | item: Name of the item. 1644 | 1645 | Returns: 1646 | Boolean - True if enabled, False if not. 1647 | """ 1648 | self._verify_enabled() 1649 | el = self.item_element(item) 1650 | li = self.browser.element("..", parent=el) 1651 | return "disabled" not in self.browser.classes(li) 1652 | 1653 | def item_select(self, item, handle_alert=None): 1654 | """Opens the dropdown and selects the desired item. 1655 | 1656 | Args: 1657 | item: Item to be selected 1658 | handle_alert: How to handle alerts. None - no handling, True - confirm, False - dismiss. 1659 | """ 1660 | self.logger.info("Selecting %r", item) 1661 | try: 1662 | self.open() 1663 | if not self.item_enabled(item): 1664 | reason = self.item_title(item) 1665 | raise DropdownItemDisabled( 1666 | 'Item "{item}" of dropdown "{dropdown}" is disabled due to \n' 1667 | "{reason}" 1668 | "The following items are available: {available}".format( 1669 | item=item, dropdown=self.text, reason=reason, available=";".join(self.items) 1670 | ) 1671 | ) 1672 | self.browser.click(self.item_element(item), ignore_ajax=handle_alert is not None) 1673 | if handle_alert is not None: 1674 | self.browser.handle_alert(cancel=not handle_alert, wait=10.0) 1675 | self.browser.plugin.ensure_page_safe() 1676 | finally: 1677 | try: 1678 | self.close(ignore_nonpresent=True) 1679 | except UnexpectedAlertPresentException: 1680 | self.logger.warning("There is an unexpected alert present.") 1681 | pass 1682 | 1683 | @property 1684 | def hover(self): 1685 | # title will act as hover for disabled Dropdown 1686 | return self.browser.element(self.BUTTON_LOCATOR).get_attribute("title") 1687 | 1688 | def __repr__(self): 1689 | return f"{type(self).__name__}({self.text!r})" 1690 | 1691 | 1692 | class SelectorDropdown(Dropdown): 1693 | """A variant of :py:class:`Dropdown` which allows selecting values. 1694 | 1695 | Unlike :py:class:`Dropdown` it supports read and fill because it usually does not change pages 1696 | like ordinary dropdown does. 1697 | 1698 | Args: 1699 | button_attr: Name of the attribute matched on the button inside the dropdown div 1700 | button_attr_value: The value to match on that attr 1701 | """ 1702 | 1703 | ROOT = ParametrizedLocator( 1704 | './/div[contains(@class, "dropdown") and ./button[@{@b_attr}={@b_attr_value|quote}]]' 1705 | ) 1706 | 1707 | def __init__(self, parent, button_attr, button_attr_value, logger=None): 1708 | # Skipping Dropdown init because it has nothing interesting for us 1709 | Widget.__init__(self, parent, logger=logger) 1710 | self.b_attr = button_attr 1711 | self.b_attr_value = button_attr_value 1712 | 1713 | def item_select(self, item, *args, **kwargs): 1714 | super().item_select(item, *args, **kwargs) 1715 | wait_for(lambda: self.currently_selected == item, num_sec=3, delay=0.2) 1716 | 1717 | def fill(self, value): 1718 | if value == self.currently_selected: 1719 | return False 1720 | self.item_select(value) 1721 | return True 1722 | 1723 | def __repr__(self): 1724 | return f"{type(self).__name__}({self.b_attr!r}, {self.b_attr_value!r})" 1725 | 1726 | 1727 | class BootstrapSwitch(BaseInput): 1728 | """represents checkbox like switch control. Widgetastic checkbox doesn't work right for 1729 | this control. 1730 | .. code-block:: python 1731 | 1732 | switch = BootstrapSwitch(id="default_tls_verify"') 1733 | switch.fill(True) 1734 | switch.read() 1735 | """ 1736 | 1737 | PARENT = "./.." 1738 | ROOT = ParametrizedLocator( 1739 | "|".join( 1740 | [ 1741 | ".//div/text()[normalize-space(.)={@label|quote}]/" 1742 | "preceding-sibling::div[1]//" 1743 | 'div[contains(@class, "bootstrap-switch-container")]' 1744 | "{@input}", 1745 | './/div/div[contains(@class, "bootstrap-switch-container")]' "{@input}", 1746 | ] 1747 | ) 1748 | ) 1749 | 1750 | def __init__(self, parent, id=None, name=None, label=None, logger=None): 1751 | self._label = label 1752 | if not (id or name or self._label): 1753 | raise ValueError("either id, name or label should be present") 1754 | elif name is not None and self._label is None: 1755 | self.input = f"//input[@name={quote(name)}]" 1756 | self.label = "" 1757 | elif id is not None and self._label is None: 1758 | self.input = f"//input[@id={quote(id)}]" 1759 | self.label = "" 1760 | elif self._label is not None and name is None and id is None: 1761 | self.input = "//input" 1762 | self.label = self._label 1763 | else: 1764 | raise ValueError("label, id and name cannot be used together") 1765 | 1766 | BaseInput.__init__(self, parent, locator=self.ROOT, logger=logger) 1767 | 1768 | @property 1769 | def selected(self): 1770 | # it seems there is a bug in patternfly lib because in some cases 1771 | # BootstrapSwitch->input.checked returns False when control is definitely checked 1772 | classes = self.browser.classes(self) 1773 | if "ng-not-empty" in classes: 1774 | return True 1775 | elif "ng-empty" in classes: 1776 | return False 1777 | else: 1778 | return self.browser.is_selected(self) 1779 | 1780 | @property 1781 | def is_displayed(self): 1782 | return self.browser.is_displayed(locator=self.PARENT, parent=self) 1783 | 1784 | @property 1785 | def _clickable_el(self): 1786 | """input itself is not clickable because it's hidden, instead we should click on a parent 1787 | element e.g. div. 1788 | 1789 | Returns: selenium webelement 1790 | """ 1791 | return self.browser.element(parent=self, locator=self.PARENT) 1792 | 1793 | def fill(self, value): 1794 | value = bool(value) 1795 | current_value = self.selected 1796 | if value == current_value: 1797 | return False 1798 | else: 1799 | self.browser.click(self._clickable_el) 1800 | if self.selected != value: 1801 | raise WidgetOperationFailed( 1802 | "Failed to set the bootstrap switch to requested value." 1803 | ) 1804 | return True 1805 | 1806 | def read(self): 1807 | return self.selected 1808 | 1809 | 1810 | class AboutModal(Widget): 1811 | """ 1812 | Represents the patternfly about modal 1813 | 1814 | Provides a close method 1815 | """ 1816 | 1817 | # ROOT_LOC only used when id is not passed to constructor 1818 | ROOT_LOC = ( 1819 | '//div[contains(@class, "modal") and contains(@class, "fade") ' 1820 | 'and .//div[contains(@class, "about-modal-pf")]]' 1821 | ) 1822 | CLOSE_LOC = './/div[@class="modal-header"]/button[@class="close" and @data-dismiss="modal"]' 1823 | ITEMS_LOC = './/div[@class="modal-body"]/div[@class="product-versions-pf"]/ul/li' 1824 | # These are relative to the
  • elements under ITEMS_LOC above 1825 | LABEL_LOC = "./strong" 1826 | # widgets for the title+trademark lines 1827 | TITLE_LOC = './/div[@class="modal-body"]/*[self::h1 or self::h2]' 1828 | TRADEMARK_LOC = './/div[@class="modal-body"]/div[@class="trademark-pf"]' 1829 | 1830 | def __init__(self, parent, id=None, logger=None): 1831 | Widget.__init__(self, parent, logger=logger) 1832 | self.id = id 1833 | 1834 | def __locator__(self): 1835 | """If id was passed, parametrize it into a locator, otherwise use ROOT_LOC""" 1836 | if self.id is not None: 1837 | return ( 1838 | '//div[normalize-space(@id)="{}" and ' 1839 | 'contains(@class, "modal") and ' 1840 | 'contains(@class, "fade") and ' 1841 | './/div[contains(@class, "about-modal-pf")]]'.format(self.id) 1842 | ) 1843 | else: 1844 | return self.ROOT_LOC 1845 | 1846 | @property 1847 | def is_open(self): 1848 | """Is the about modal displayed right now""" 1849 | try: 1850 | return "in" in self.browser.classes(self) 1851 | except NoSuchElementException: 1852 | return False 1853 | 1854 | def close(self): 1855 | """Close the modal""" 1856 | self.browser.click(self.CLOSE_LOC, parent=self) 1857 | 1858 | @property 1859 | def title(self): 1860 | return self.browser.text(self.browser.element(self.TITLE_LOC, parent=self)) 1861 | 1862 | @property 1863 | def trademark(self): 1864 | return self.browser.text(self.browser.element(self.TRADEMARK_LOC, parent=self)) 1865 | 1866 | def items(self): 1867 | """ 1868 | Generate a dictionary of key-value pairs of fields and their values 1869 | :return: dictionary of keys matching the bold field labels and their values 1870 | """ 1871 | items = {} 1872 | list_elements = self.browser.elements(self.ITEMS_LOC, parent=self) 1873 | for element in list_elements: 1874 | # each list item has a label in a and the value following 1875 | # can't select this text after the strong via xpath and get an element 1876 | key = self.browser.text(self.LABEL_LOC, parent=element) 1877 | element_text = self.browser.text(element) 1878 | 1879 | # value will include the label from the block, parse it out 1880 | items.update({key: element_text.replace(key, "", 1).lstrip()}) 1881 | return items 1882 | 1883 | 1884 | class Modal(View): 1885 | """Patternfly modal widget 1886 | 1887 | https://www.patternfly.org/pattern-library/widgets/#modal 1888 | """ 1889 | 1890 | ROOT = './/div[contains(@class, "modal") ' 'and contains(@class, "fade") and @role="dialog"]' 1891 | 1892 | def __init__(self, parent, id=None, logger=None): 1893 | self.id = id 1894 | if id: 1895 | self.ROOT = ParametrizedLocator( 1896 | ".//div[normalize-space(@id)={@id|quote} and " 1897 | 'contains(@class, "modal") and contains(@class, "fade") ' 1898 | 'and @role="dialog"]' 1899 | ) 1900 | 1901 | View.__init__(self, parent, logger=logger) 1902 | 1903 | @property 1904 | def title(self): 1905 | return self.header.title.read() 1906 | 1907 | @property 1908 | def text(self): 1909 | """Option for compatibility with selenium alerts""" 1910 | return self.title 1911 | 1912 | @property 1913 | def is_displayed(self): 1914 | """Is the modal currently open?""" 1915 | try: 1916 | return "in" in self.browser.classes(self) 1917 | except NoSuchElementException: 1918 | return False 1919 | 1920 | def close(self): 1921 | """Close the modal""" 1922 | self.header.close.is_displayed and self.header.close.click() 1923 | 1924 | @View.nested 1925 | class header(View): # noqa 1926 | """The header of the modal""" 1927 | 1928 | ROOT = './/div[@class="modal-header"]' 1929 | close = Text(locator='.//button[@class="close"]') 1930 | title = Text(locator='.//h4[@class="modal-title"]') 1931 | 1932 | @View.nested 1933 | class body(View): # noqa 1934 | """The body of the modal""" 1935 | 1936 | ROOT = './/div[@class="modal-body"]' 1937 | body_text = Text(locator=".//h4") 1938 | 1939 | @View.nested 1940 | class footer(View): # noqa 1941 | """The footer of the modal""" 1942 | 1943 | ROOT = './/div[@class="modal-footer"]' 1944 | dismiss = Button("Cancel") 1945 | accept = Button(classes=Button.PRIMARY) 1946 | 1947 | def dismiss(self): 1948 | """Cancel the modal""" 1949 | self.footer.dismiss.click() 1950 | 1951 | def accept(self): 1952 | """Submit/Save/Accept/Delete for the modal.""" 1953 | self.footer.accept.click() 1954 | 1955 | 1956 | class BreadCrumb(Widget): 1957 | """Patternfly BreadCrumb navigation control 1958 | 1959 | .. code-block:: python 1960 | 1961 | breadcrumb = BreadCrumb() 1962 | breadcrumb.click_location(breadcrumb.locations[0]) 1963 | """ 1964 | 1965 | ROOT = '//ol[contains(@class, "breadcrumb")]' 1966 | ELEMENTS = ".//li" 1967 | LINK = ".//a" 1968 | 1969 | def __init__(self, parent, locator=None, logger=None): 1970 | Widget.__init__(self, parent=parent, logger=logger) 1971 | self._locator = locator or self.ROOT 1972 | 1973 | def __locator__(self): 1974 | return self._locator 1975 | 1976 | @property 1977 | def _path_elements(self): 1978 | return self.browser.elements(self.ELEMENTS, parent=self) 1979 | 1980 | @property 1981 | def locations(self): 1982 | return [self.browser.text(loc) for loc in self._path_elements] 1983 | 1984 | @property 1985 | def active_location(self): 1986 | br = self.browser 1987 | return next(br.text(loc) for loc in self._path_elements if "active" in br.classes(loc)) 1988 | 1989 | def click_location(self, name, handle_alert=True): 1990 | br = self.browser 1991 | try: 1992 | location = next(loc for loc in self._path_elements if br.text(loc) == name) 1993 | except StopIteration: 1994 | self.logger.exception(f"Given location name [{name}] not found") 1995 | raise WidgetOperationFailed("Unable to click breadcrumb location, location not found") 1996 | result = br.click(br.element(self.LINK, parent=location), ignore_ajax=handle_alert) 1997 | if handle_alert: 1998 | self.browser.handle_alert(wait=2.0, squash=True) 1999 | self.browser.plugin.ensure_page_safe() 2000 | return result 2001 | 2002 | def read(self): 2003 | """Return the active location of the breadcrumb""" 2004 | return self.active_location 2005 | 2006 | 2007 | class DatePicker(View): 2008 | """Represents the Bootstrap DatePicker. 2009 | 2010 | Args: 2011 | name: Name of DatePicker 2012 | id: Id of DatePicker 2013 | locator: If none of the above applies, you can also supply a full locator 2014 | strptime_format: `datetime` module `strptime` format. The default is for `mm/dd/yyyy` but 2015 | the user can overwrite as per widget requirement which should comparable with datetime. 2016 | 2017 | .. code-block:: python 2018 | date = DatePicker(name='miq_date_1') 2019 | 2020 | # check readonly or editable 2021 | date.is_readonly 2022 | # fill current date 2023 | date.fill(datetime.now()) 2024 | # read selected date for DatePicker 2025 | date.read() 2026 | """ 2027 | 2028 | textbox = TextInput(locator=Parameter("@locator")) 2029 | 2030 | def __init__( 2031 | self, parent, id=None, name=None, strptime_format="%m/%d/%Y", locator=None, logger=None 2032 | ): # noqa 2033 | View.__init__(self, parent=parent, logger=logger) 2034 | 2035 | self.strptime_format = strptime_format 2036 | base_locator = ".//*[(self::input or self::textarea) and @{}={}]" 2037 | 2038 | if id: 2039 | self.locator = base_locator.format("id", quote(id)) 2040 | elif name: 2041 | self.locator = base_locator.format("name", quote(name)) 2042 | elif locator: 2043 | self.locator = locator 2044 | else: 2045 | raise TypeError("You need to specify either, id, name or locator for DatePicker") 2046 | 2047 | class HeaderView(View): 2048 | prev_button = Text(".//*[contains(@class, 'prev')]") 2049 | next_button = Text(".//*[contains(@class, 'next')]") 2050 | datepicker_switch = Text(".//*[contains(@class, 'datepicker-switch')]") 2051 | _elements = {} 2052 | 2053 | def select(self, value): 2054 | for el, web_el in self._elements.items(): 2055 | if el == value: 2056 | web_el.click() 2057 | return True 2058 | 2059 | @property 2060 | def active(self): 2061 | for el, web_el in self._elements.items(): 2062 | if bool(self.browser.classes(web_el) & {"active", "focused"}): 2063 | return el 2064 | 2065 | @View.nested 2066 | class date_pick(HeaderView): # noqa 2067 | ROOT = ".//*[contains(@class, 'datepicker-days')]" 2068 | DATES = ".//table/tbody/tr/td" 2069 | 2070 | @property 2071 | def _elements(self): 2072 | dates = {} 2073 | for el in self.browser.elements(self.DATES): 2074 | if not bool({"old", "new", "disabled"} & self.browser.classes(el)): 2075 | dates.update({int(el.text): el}) 2076 | return dates 2077 | 2078 | @View.nested 2079 | class month_pick(HeaderView): # noqa 2080 | ROOT = ".//*[contains(@class, 'datepicker-months')]" 2081 | MONTHS = ".//table/tbody/tr/td/*" 2082 | 2083 | @property 2084 | def _elements(self): 2085 | months = {} 2086 | for el in self.browser.elements(self.MONTHS): 2087 | if not bool({"disabled"} & self.browser.classes(el)): 2088 | months.update({el.text: el}) 2089 | return months 2090 | 2091 | @View.nested 2092 | class year_pick(HeaderView): # noqa 2093 | ROOT = ".//*[contains(@class, 'datepicker-years')]" 2094 | YEARS = ".//table/tbody/tr/td/*" 2095 | 2096 | @property 2097 | def _elements(self): 2098 | years = {} 2099 | for el in self.browser.elements(self.YEARS): 2100 | if not bool({"old", "new", "disabled"} & self.browser.classes(el)): 2101 | years.update({int(el.text): el}) 2102 | return years 2103 | 2104 | def _pick(self, value): 2105 | for el, web_el in self._elements.items(): 2106 | if el == value: 2107 | web_el.click() 2108 | return True 2109 | 2110 | def select(self, value): 2111 | start_yr, end_yr = (int(item) for item in self.datepicker_switch.read().split("-")) 2112 | if value > end_yr: 2113 | for _ in range(end_yr, value, 10): 2114 | self.next_button.click() 2115 | elif value < start_yr: 2116 | for _ in range(start_yr, value, -10): 2117 | self.prev_button.click() 2118 | self._pick(value) 2119 | 2120 | def read(self): 2121 | """Read the current date form DatePicker 2122 | 2123 | Returns: 2124 | :py:class:`datetime` 2125 | """ 2126 | try: 2127 | return datetime.strptime(self.textbox.value, self.strptime_format) 2128 | except ValueError: 2129 | return None 2130 | 2131 | def fill(self, value): 2132 | """Fill date to DatePicker 2133 | 2134 | Args: 2135 | value: datetime object. 2136 | 2137 | Returns: 2138 | :py:class:`bool` 2139 | """ 2140 | current_date = self.read() 2141 | if current_date and value.date() == current_date.date(): 2142 | return False 2143 | 2144 | if not self.readonly: 2145 | date = datetime.strftime(value, self.strptime_format) 2146 | self.textbox.fill(date) 2147 | self.date_pick._elements[self.date_pick.active].click() 2148 | return True 2149 | else: 2150 | self.browser.click(self.textbox) 2151 | self.date_pick.datepicker_switch.click() 2152 | self.month_pick.datepicker_switch.click() 2153 | self.year_pick.select(value=value.year) 2154 | self.month_pick.select(value=value.strftime("%b")) 2155 | self.date_pick.select(value=value.day) 2156 | return True 2157 | 2158 | @property 2159 | def readonly(self): 2160 | """DatePicker is editable or not 2161 | 2162 | Returns: 2163 | :py:class:`bool` 2164 | """ 2165 | return bool(self.browser.get_attribute("readonly", self.textbox)) 2166 | 2167 | @property 2168 | def date_format(self): 2169 | """DatePicker date format 2170 | 2171 | Returns: 2172 | :py:class:`str` 2173 | """ 2174 | return self.browser.get_attribute("data-date-format", self.textbox) 2175 | 2176 | @property 2177 | def is_displayed(self): 2178 | """DatePicker displayed or not 2179 | 2180 | Returns: 2181 | :py:class:`bool` 2182 | """ 2183 | return self.browser.is_displayed(self.textbox) 2184 | 2185 | 2186 | class StatusNotification(Widget): 2187 | """Class for the notification elements that are in aggregate status cards 2188 | 2189 | Provides some attributes for storing the notification class based on icon constants 2190 | And a click method for those notifications tied to an anchor 2191 | """ 2192 | 2193 | # Notification count will be in anchor with an icon 2194 | ANCHOR = "./a" 2195 | TEXT = "./*[normalize-space(.)]" 2196 | 2197 | def __init__(self, parent, note_element, logger): 2198 | Widget.__init__(self, parent=parent, logger=logger) 2199 | self.note_element = note_element 2200 | 2201 | def __locator__(self): 2202 | return self.note_element 2203 | 2204 | @property 2205 | def icon(self): 2206 | """Icon constant for the notification span 2207 | 2208 | Returns: 2209 | None if no icon is found in the title element 2210 | PFIcon constant if icon found 2211 | """ 2212 | try: 2213 | return PFIcon.icon_from_element(self.note_element, browser=self.browser) 2214 | except NoSuchElementException: 2215 | return None 2216 | 2217 | @property 2218 | def text(self): 2219 | """text associated with the notification, likely a count 2220 | 2221 | Returns: 2222 | None if no text is found in the notification element 2223 | str text from the element 2224 | """ 2225 | try: 2226 | return self.browser.text(self.TEXT, parent=self) 2227 | except NoSuchElementException: 2228 | return None 2229 | 2230 | def read(self): 2231 | """Read the notification attributes and return a dict 2232 | 2233 | Returns: 2234 | dict containing icon and text attributes 2235 | """ 2236 | return {"icon": self.icon, "text": self.text} 2237 | 2238 | def click(self): 2239 | """Click the anchor for this notification 2240 | 2241 | Raises: 2242 | NoSuchElementException: when there is no anchor to click 2243 | """ 2244 | self.browser.click(self.ANCHOR, parent=self) 2245 | 2246 | 2247 | class AggregateStatusCard(View): 2248 | """Widget for patternfly aggregate status card, used in dashboard views 2249 | 2250 | This covers the standard type card 2251 | https://www.patternfly.org/pattern-library/cards/aggregate-status-card/#example-code-1 2252 | """ 2253 | 2254 | # Get the aggregate-status card div, per patternfly reference markup 2255 | ROOT = ParametrizedLocator( 2256 | './/div[contains(@class, "card-pf-aggregate-status") ' 2257 | 'and not(contains(@class, "card-pf-aggregate-status-mini")) ' 2258 | 'and h2[contains(@class, "card-pf-title")]' 2259 | "//span[normalize-space(following::text())={@name|quote}]]" 2260 | ) 2261 | 2262 | # count is in span with specific class under main card div 2263 | TITLE = './h2[contains(@class, "card-pf-title")]' 2264 | COUNT = './/span[contains(@class, "card-pf-aggregate-status-count")]' 2265 | TITLE_ANCHOR = "/a" 2266 | 2267 | BODY = './div[contains(@class, "card-pf-body")]' 2268 | NOTIFICATION = ( 2269 | './p[contains(@class, "card-pf-aggregate-status-notifications")]' 2270 | '//span[contains(@class, "card-pf-aggregate-status-notification")]' 2271 | ) 2272 | 2273 | ACTION_ANCHOR = ParametrizedLocator( 2274 | ".//a[@title={@action_title|quote} " "or @data-original-title={@action_title|quote}]" 2275 | ) 2276 | 2277 | def __init__(self, parent, name, locator=None, action_title=None, logger=None): 2278 | """Constructor, using name, can specify locator 2279 | 2280 | Args: 2281 | name: string name of the status card, displayed with count in top line 2282 | action_title: title attribute for the anchor of the action link in notification block 2283 | In the patternfly ref, this is an 'add' action with icon 2284 | """ 2285 | Widget.__init__(self, parent=parent, logger=logger) 2286 | self.name = name 2287 | self.locator = locator or self.ROOT 2288 | self.action_title = action_title 2289 | 2290 | def __locator__(self): 2291 | return self.locator 2292 | 2293 | @property 2294 | def _title(self): 2295 | """tool for local methods to get the title element""" 2296 | return self.browser.element(self.TITLE, parent=self) 2297 | 2298 | @property 2299 | def _body(self): 2300 | """tool for local methods to get the body element""" 2301 | return self.browser.element(self.BODY, parent=self) 2302 | 2303 | @property 2304 | def count(self): 2305 | """count in the title 2306 | 2307 | Returns: 2308 | None if no count element is found 2309 | int count from the element 2310 | """ 2311 | try: 2312 | return int(self.browser.text(self.browser.element(self.COUNT, parent=self._title))) 2313 | except NoSuchElementException: 2314 | return None 2315 | 2316 | @property 2317 | def icon(self): 2318 | """icon of the title 2319 | 2320 | Returns: 2321 | None if no icon is found in the title element 2322 | PFIcon constant if icon found 2323 | """ 2324 | try: 2325 | return PFIcon.icon_from_element(element=self._title, browser=self.browser) 2326 | except NoSuchElementException: 2327 | return None 2328 | 2329 | @property 2330 | def notifications(self): 2331 | """read method for the status notifications in the body of the card 2332 | 2333 | Returns 2334 | list of notification elements, empty when there are none 2335 | """ 2336 | try: 2337 | notes = self.browser.elements(self.NOTIFICATION, parent=self._body) 2338 | except NoSuchElementException: 2339 | return [] 2340 | return [ 2341 | StatusNotification(parent=self, note_element=note, logger=self.logger) for note in notes 2342 | ] 2343 | 2344 | def read(self): 2345 | items = dict(icon=self.icon, count=self.count, name=self.name) 2346 | items.update({"notifications": [note.read() for note in self.notifications]}) 2347 | return items 2348 | 2349 | def click(self): 2350 | self.click_title() 2351 | 2352 | def click_title(self): 2353 | self.browser.click(self.TITLE_ANCHOR, parent=self) 2354 | 2355 | def click_body_action(self): 2356 | if self.action_title: 2357 | self.browser.click(self.ACTION_ANCHOR, parent=self) 2358 | else: 2359 | raise LocatorNotImplemented("No action_title, cannot locate action element for click") 2360 | 2361 | 2362 | class AggregateStatusMiniCard(AggregateStatusCard): 2363 | """Widget for the mini type of aggregate status card 2364 | 2365 | slightly different display and locator 2366 | https://www.patternfly.org/pattern-library/cards/aggregate-status-card/#example-code-2 2367 | """ 2368 | 2369 | # TODO testing of parent methods against mini card 2370 | ROOT = ParametrizedLocator( 2371 | './/div[contains(@class, "card-pf-aggregate-status") ' 2372 | 'and contains(@class, "card-pf-aggregate-status-mini") ' 2373 | 'and h2[contains(@class, "card-pf-title")]' 2374 | "//span[normalize-space(following::text())={@name|quote}]]" 2375 | ) 2376 | 2377 | 2378 | class Kebab(Widget): 2379 | """Patternfly Kebab menu widget 2380 | 2381 | Args: 2382 | id: Id of Kebab button 2383 | locator: Kebab button locator 2384 | """ 2385 | 2386 | ROOT = ParametrizedLocator("{@locator}") 2387 | BASE_LOCATOR = ".//div[contains(@class, 'dropdown-kebab-pf') and ./button[@id={}]]" 2388 | UL = './ul[contains(@class, "dropdown-menu")]' 2389 | BUTTON = "./button" 2390 | ITEM = "./ul/li/a[normalize-space(.)={}]" 2391 | ITEMS = "./ul/li/a" 2392 | 2393 | def __init__(self, parent, id=None, locator=None, logger=None): 2394 | Widget.__init__(self, parent=parent, logger=logger) 2395 | 2396 | if id: 2397 | self.locator = self.BASE_LOCATOR.format(quote(id)) 2398 | elif locator: 2399 | self.locator = locator 2400 | else: 2401 | raise TypeError("You need to specify either id or locator") 2402 | 2403 | @property 2404 | def is_opened(self): 2405 | """Returns opened state of the kebab.""" 2406 | return self.browser.is_displayed(self.UL) 2407 | 2408 | @property 2409 | def items(self): 2410 | """Lists all items in the kebab. 2411 | 2412 | Returns: 2413 | :py:class:`list` of :py:class:`str` 2414 | """ 2415 | return [self.browser.text(item) for item in self.browser.elements(self.ITEMS)] 2416 | 2417 | def has_item(self, item): 2418 | """Returns whether the items exists. 2419 | 2420 | Args: 2421 | item: item name 2422 | 2423 | Returns: 2424 | Boolean - True if enabled, False if not. 2425 | """ 2426 | return item in self.items 2427 | 2428 | def open(self): 2429 | """Open the kebab""" 2430 | if not self.is_opened: 2431 | self.browser.click(self.BUTTON) 2432 | 2433 | def close(self): 2434 | """Close the kebab""" 2435 | if self.is_opened: 2436 | self.browser.click(self.BUTTON) 2437 | 2438 | def item_select(self, item, close=True): 2439 | """Select a specific item from the kebab. 2440 | 2441 | Args: 2442 | item: Item to be selected. 2443 | close: Whether to close the kebab after selection. If the item is a link, you may want 2444 | to set this to `False`. 2445 | """ 2446 | try: 2447 | el = self.browser.element(self.ITEM.format(quote(item))) 2448 | self.open() 2449 | self.parent_browser.click(el) 2450 | finally: 2451 | if close: 2452 | self.close() 2453 | 2454 | 2455 | class SparkLineChart(Widget, ClickableMixin): 2456 | """Represents the Spark Line Chart from Patternfly (Data Visualization). 2457 | 2458 | Args: 2459 | id: id of SparkLineChart 2460 | locator: If id not applies, you can also supply a full locator 2461 | 2462 | .. code-block:: python 2463 | spark_line_chart = SparkLineChart(id="sparklineChart") 2464 | """ 2465 | 2466 | ROOT = ParametrizedLocator("{@locator}") 2467 | BASE_LOCATOR = ".//div[@id={}]" 2468 | # axis event mapping 2469 | RECTS = ".//*[contains(@class, 'c3-event-rects c3-event-rects-single')]//*" 2470 | tooltip = Text(".//div[contains(@class,'c3-tooltip-container')]") 2471 | 2472 | def __init__(self, parent, id=None, locator=None, logger=None): 2473 | """Create the widget""" 2474 | Widget.__init__(self, parent, logger=logger) 2475 | if id: 2476 | self.locator = self.BASE_LOCATOR.format(quote(id)) 2477 | elif locator: 2478 | self.locator = locator 2479 | else: 2480 | raise TypeError("You need to specify either id or locator") 2481 | 2482 | def read(self): 2483 | """read all data on chart 2484 | 2485 | Returns: 2486 | :py:class:`list` complete data on chart 2487 | """ 2488 | data = [] 2489 | for el in self.browser.elements(self.RECTS): 2490 | self.browser.move_to_element(el) 2491 | data.append(self.tooltip.read()) 2492 | return data 2493 | 2494 | 2495 | class SingleLineChart(SparkLineChart): 2496 | """Represents the Single Line Chart from Patternfly (Data Visualization). 2497 | 2498 | Args: 2499 | id: id of SingleLineChart 2500 | locator: If id not applies, you can also supply a full locator 2501 | 2502 | .. code-block:: python 2503 | single_line_chart = SingleLineChart(id="singleLineChart") 2504 | """ 2505 | 2506 | X_AXIS = ".//*[contains(@class, 'c3-axis c3-axis-x')]/*[contains(@class, 'tick')]" 2507 | tooltip = Table(locator=".//div[contains(@class,'c3-tooltip-container')]/table") 2508 | 2509 | @property 2510 | def _elements(self): 2511 | br = self.browser 2512 | return { 2513 | x.get_attribute("textContent"): el 2514 | for (x, el) in zip(br.elements(self.X_AXIS), br.elements(self.RECTS)) 2515 | } 2516 | 2517 | def _get_data(self, elements): 2518 | data = {} 2519 | for el in elements: 2520 | self.tooltip.clear_cache() 2521 | self.browser.move_to_element(el) 2522 | raw_data = {row[0].text: row[1].text for row in self.tooltip.rows()} 2523 | if self.tooltip.headers: 2524 | tooltip_data = {self.tooltip.headers[0]: raw_data} 2525 | else: 2526 | # In the absence of a header, `:` separates x-axis points and values. 2527 | tooltip_data = { 2528 | h[:-1] if (h[-1] == ":") else h: value for h, value in raw_data.items() 2529 | } 2530 | data.update(tooltip_data) 2531 | return data 2532 | 2533 | def read(self): 2534 | """read all data on chart 2535 | 2536 | Returns: 2537 | :py:class:`dict` complete data on chart 2538 | """ 2539 | return self._get_data(self._elements.values()) 2540 | 2541 | def get_values(self, x_axis): 2542 | """data for specific x-axis point on chart 2543 | 2544 | Args: 2545 | x_axis: x-axis point as per chart 2546 | 2547 | Returns: 2548 | :py:class:`dict` data for selected timestamp 2549 | """ 2550 | el = [self._elements.get(x_axis)] 2551 | return self._get_data(el) 2552 | 2553 | 2554 | class LineChart(SingleLineChart): 2555 | """Represents the Line Chart having legends from Patternfly (Data Visualization). 2556 | 2557 | Args: 2558 | id: id of LineChart 2559 | locator: If id not applies, you can also supply a full locator 2560 | 2561 | .. code-block:: python 2562 | line_chart = LineChart(id="lineChart") 2563 | """ 2564 | 2565 | LEGENDS = ".//*[contains(@class, 'c3-legend-item c3-legend-item-')]" 2566 | 2567 | @property 2568 | def _legends(self): 2569 | return {self.browser.text(leg): leg for leg in self.browser.elements(self.LEGENDS)} 2570 | 2571 | @property 2572 | def legends(self): 2573 | """Get all available legends 2574 | 2575 | Returns: 2576 | :py:class:`list` all available legends 2577 | """ 2578 | return list(self._legends.keys()) 2579 | 2580 | def legend_is_displayed(self, leg): 2581 | """Check legend is available or not on Chart 2582 | Args: 2583 | leg: `str` of legend 2584 | 2585 | Returns: 2586 | :py:class:`bool` all available legends 2587 | """ 2588 | if isinstance(leg, str): 2589 | leg = self._legends.get(leg, None) 2590 | 2591 | if leg: 2592 | return "c3-legend-item-hidden" not in self.browser.classes(leg) 2593 | else: 2594 | return False 2595 | 2596 | def hide_all_legends(self): 2597 | """To hide all legends on chart""" 2598 | for legend in self._legends.values(): 2599 | if self.legend_is_displayed(legend): 2600 | self.browser.click(legend) 2601 | 2602 | def display_all_legends(self): 2603 | """To display all legends on chart""" 2604 | for legend in self._legends.values(): 2605 | if not self.legend_is_displayed(legend): 2606 | self.browser.click(legend) 2607 | 2608 | def display_legends(self, *legends): 2609 | """Display one or more legends on chart 2610 | 2611 | Args: 2612 | legends: One or Multiple legends name 2613 | """ 2614 | for legend in legends: 2615 | leg = self._legends.get(legend) 2616 | if not self.legend_is_displayed(leg): 2617 | self.browser.click(leg) 2618 | 2619 | def hide_legends(self, *legends): 2620 | """Hide one or more legends on chart 2621 | 2622 | Args: 2623 | legends: One or Multiple legends name 2624 | """ 2625 | for legend in legends: 2626 | leg = self._legends.get(legend) 2627 | if self.legend_is_displayed(leg): 2628 | self.browser.click(leg) 2629 | 2630 | def get_data_for_legends(self, *legends): 2631 | """data for specific legends on chart 2632 | 2633 | Args: 2634 | legends: one or more legends 2635 | 2636 | Returns: 2637 | :py:class:`dict` data for selected legends 2638 | """ 2639 | self.hide_all_legends() 2640 | self.display_legends(*legends) 2641 | return self._get_data(self._elements.values()) 2642 | 2643 | def read(self): 2644 | """read all data on chart 2645 | 2646 | Returns: 2647 | :py:class:`dict` complete data on chart 2648 | """ 2649 | self.display_all_legends() 2650 | return self._get_data(self._elements.values()) 2651 | 2652 | 2653 | class SingleSplineChart(SingleLineChart): 2654 | """Represents the Single Spline Chart from Patternfly (Data Visualization).""" 2655 | 2656 | pass 2657 | 2658 | 2659 | class SplineChart(LineChart): 2660 | """Represents the Spline Chart having legends from Patternfly (Data Visualization).""" 2661 | 2662 | pass 2663 | 2664 | 2665 | class BarChart(SingleLineChart): 2666 | """Represents the Vertical/Horizontal Bar Chart from Patternfly (Data Visualization).""" 2667 | 2668 | pass 2669 | 2670 | 2671 | class GroupedBarChart(LineChart): 2672 | """Represents the Grouped Vertical/Horizontal/Stacked Bar Chart (Having legends) 2673 | from Patternfly (Data Visualization). 2674 | """ 2675 | 2676 | pass 2677 | 2678 | 2679 | class ListItem(ParametrizedView): 2680 | """Basic item object for use with ItemsList""" 2681 | 2682 | PARAMETERS = ("index",) 2683 | ROOT = ParametrizedLocator('.//div[contains(@class,"list-group-item") and position()={index}]') 2684 | DESCRIPTION_LOCATOR = './/span[contains(@class,"description-column")]' 2685 | EXPAND_LOCATOR = f'.//span[contains(@class,"{PFIcon.icons.ANGLE_RIGHT}")]' 2686 | COLLAPSE_LOCATOR = f'.//span[contains(@class,"{PFIcon.icons.ANGLE_DOWN}")]' 2687 | 2688 | # note that this has 1-based indexing 2689 | index = Parameter("index") 2690 | 2691 | # properties 2692 | @property 2693 | def item_list(self): 2694 | return self.parent 2695 | 2696 | @property 2697 | def description(self): 2698 | desc = self.browser.element(self.DESCRIPTION_LOCATOR, parent=self) 2699 | return self.browser.text(desc) 2700 | 2701 | # methods 2702 | def open(self): 2703 | expand_arrow = self.browser.element(self.EXPAND_LOCATOR, parent=self) 2704 | self.browser.click(expand_arrow) 2705 | 2706 | def close(self): 2707 | collapse_arrow = self.browser.element(self.COLLAPSE_LOCATOR, parent=self) 2708 | self.browser.click(collapse_arrow) 2709 | 2710 | def read(self): 2711 | return self.browser.text(self) 2712 | 2713 | 2714 | class ItemsList(View): 2715 | """Basic list-view handling class: 2716 | https://www.patternfly.org/pattern-library/content-views/list-view/ 2717 | Most functionality is meant to mimic widgetastic's Table class. 2718 | This class is meant to work with an ItemClass that is defined elsewhere. The ItemClass 2719 | is a ParametrizedView that represents a single item in the list-view 2720 | (similar to TableRow and Table). The parameter for the ItemClass is it's index/position in 2721 | the list-view. 2722 | 2723 | In practice, the ItemClass will be defined within a nested view e.g. in a view class we could 2724 | have: 2725 | .. code-block:: python 2726 | # define a view 2727 | class MyView(BaseLoggedInPage): 2728 | # define the list-view widget that's on this page 2729 | @View.nested 2730 | class item_list(ItemsList): 2731 | # define the item_class that the list-view uses (default is ListItem) 2732 | item_class = ItemClass 2733 | # define a default assoc_column that can be used for filtering 2734 | assoc_column = 2735 | # define whatever else this page has on it ... 2736 | 2737 | For an example: integration_tests/cfme/control/explorer/alerts.py::MonitorAlertsAllView 2738 | 2739 | Usage is as follows assuming the list is instantiated as ``view.item_list``: 2740 | 2741 | .. code-block:: python 2742 | # Access item by position 2743 | view.item_list[0] # => gives first item in list-view 2744 | # Iterate through rows 2745 | for item in view.item_list: 2746 | do_something() 2747 | # You can also filter items in two main ways 2748 | # 1) by an assoc_column that corresponds to an attribute of the item_class e.g. 2749 | item_list = view.item_list 2750 | item_list.assoc_column = 'description' # if assoc_column was not defined in item_list def 2751 | filtered_items = item_list[] # where is a str 2752 | # 2) by key-value pairs 2753 | item_filter = {'description': } 2754 | filtered_items = view.item_list[item_filter] 2755 | # note that filters can also be applied to the items() method 2756 | # e.g. 2757 | filtered_items = view.item_list.items(item_filter) 2758 | 2759 | Args: 2760 | assoc_column: Name of an attribute/property defined in the item_class 2761 | """ 2762 | 2763 | ROOT = './/div[contains(@class,"list-view-pf-view")]' 2764 | ITEMS = './/div[contains(@class,"list-group-item-header")]' 2765 | item_class = ListItem # default item_class 2766 | 2767 | def __init__(self, parent, assoc_column=None, logger=None): 2768 | View.__init__(self, parent, logger=logger) 2769 | self._assoc_column = assoc_column 2770 | 2771 | def __getitem__(self, item_filter): 2772 | """allows the ability to directly select AlertItem by a filter with 2773 | item = view.alerts_list[item_filter], 2774 | item_filter can be of type: string, dict, int, or None 2775 | """ 2776 | if isinstance(item_filter, int): 2777 | return next(self.items(item_filter)) 2778 | elif isinstance(item_filter, str) or isinstance(item_filter, dict) or item_filter is None: 2779 | return self.items(item_filter) 2780 | else: 2781 | raise ValueError( 2782 | "item_filter is of {} but must be of type: " 2783 | "str, dict, int, or None.".format(type(item_filter)) 2784 | ) 2785 | 2786 | # properties 2787 | @property 2788 | def assoc_column(self): 2789 | return self._assoc_column or "description" # note the defualt 2790 | 2791 | @property 2792 | def item_count(self): 2793 | """returns how many rows are currently in the table.""" 2794 | return len(self.browser.elements(self.ITEMS, parent=self)) 2795 | 2796 | # methods 2797 | def items(self, item_filter=None): 2798 | """returns a generator for all Items matching the item_filter""" 2799 | start = 1 # start at 1 and not 0 since position() returns 1 as the first index 2800 | stop = self.item_count + 1 2801 | # filter via key, value pair 2802 | if isinstance(item_filter, dict): 2803 | key, value = next(item_filter.items()) 2804 | if len(item_filter) > 1: 2805 | self.logger.warning( 2806 | "List-view filter currently not implemented for dictionaries" 2807 | "greater than a length of one. Selecting the first key value pair from the dict" 2808 | ) 2809 | # filter via string, note the default 2810 | elif isinstance(item_filter, str): 2811 | key = self.assoc_column 2812 | value = item_filter 2813 | # filter via index (note that this is used via 0-based indexing 2814 | # for use with the xpath of Item a 1 must be added to the index) 2815 | elif isinstance(item_filter, int): 2816 | start = item_filter + 1 2817 | stop = start + 1 2818 | key = "None" 2819 | value = None 2820 | # no filter 2821 | elif item_filter is None: 2822 | key = "None" 2823 | value = None 2824 | else: 2825 | raise TypeError("item_filter must be of type: string, dict, int, or None!") 2826 | 2827 | for i in range(start, stop): 2828 | item = self.item_class(self, index=i) 2829 | if getattr(item, key, None) == value: 2830 | yield item 2831 | 2832 | 2833 | class FlashMessage(ParametrizedView): 2834 | """Represent a Patternfly Inline Notification: 2835 | https://www.patternfly.org/v3/pattern-library/communication/inline-notifications/ 2836 | Parametrized by the XPath index of the notification within the containing FlashMessages block. 2837 | """ 2838 | 2839 | TYPE_MAPPING = { 2840 | "alert-warning": "warning", 2841 | "alert-success": "success", 2842 | "alert-danger": "error", 2843 | "alert-info": "info", 2844 | } 2845 | 2846 | PARAMETERS = ("index",) 2847 | ROOT = ParametrizedLocator('.//div[contains(@class, "alert") and position()={index}]') 2848 | 2849 | TEXT_LOCATOR = "./strong" 2850 | DISMISS_LOCATOR = './button[contains(@class, "close")]' 2851 | ICON_LOCATOR = './span[contains(@class, "pficon")]' 2852 | 2853 | # XPath index starting at 1 2854 | index = Parameter("index") 2855 | 2856 | @property 2857 | def text(self): 2858 | """Return the message text of the notification.""" 2859 | return self.browser.text(self.TEXT_LOCATOR, parent=self) 2860 | 2861 | def dismiss(self): 2862 | """Close the notification.""" 2863 | self.logger.info(f"Dismissed notification with text {self.text!r}.") 2864 | return self.browser.click(self.DISMISS_LOCATOR, parent=self) 2865 | 2866 | @property 2867 | def icon(self): 2868 | try: 2869 | e = self.browser.element(self.ICON_LOCATOR, parent=self) 2870 | except NoSuchElementException: 2871 | return None 2872 | 2873 | for class_ in self.browser.classes(e): 2874 | if class_.startswith("pficon-"): 2875 | return class_[7:] 2876 | else: 2877 | return None 2878 | 2879 | @property 2880 | def type(self): 2881 | classes = self.browser.classes(self) 2882 | for class_ in classes: 2883 | if class_ in self.TYPE_MAPPING: 2884 | return self.TYPE_MAPPING[class_] 2885 | else: 2886 | raise ValueError( 2887 | "Could not find a proper notification type." 2888 | f" Available classes: {self.TYPE_MAPPING!r}." 2889 | f" Notification types: {classes!r}." 2890 | ) 2891 | 2892 | 2893 | class FlashMessages(View): 2894 | """Represent the div block containing the individual inline notifications.""" 2895 | 2896 | ROOT = './/div[@id="flash_msg_div"]' 2897 | MSG_LOCATOR = './div[contains(@class, "flash_text_div")]/div[contains(@class, "alert")]' 2898 | msg_class = FlashMessage 2899 | 2900 | def __getitem__(self, msg_filter): 2901 | """Allow the direct selection of a FlashMessage with a filter: 2902 | msg = view.flash[msg_filter] 2903 | msg_filter can be of type dict, int, or None. 2904 | """ 2905 | if isinstance(msg_filter, int): 2906 | return next(self.messages(index=msg_filter)) 2907 | elif isinstance(msg_filter, dict) or msg_filter is None: 2908 | return self.messages(**msg_filter) 2909 | else: 2910 | raise ValueError( 2911 | f"msg_filter {msg_filter} is of type {type(msg_filter)}" 2912 | " but must be dict, int, or None." 2913 | ) 2914 | 2915 | @property 2916 | def msg_count(self): 2917 | c = 0 2918 | try: 2919 | c = len(self.browser.elements(self.MSG_LOCATOR, parent=self)) 2920 | except NoSuchElementException: 2921 | pass 2922 | return c 2923 | 2924 | def messages(self, **msg_filter): 2925 | """Return a generator for all notifications matching the msg_filter. 2926 | The total number of notifications is re-checked each time, and the parametrized XPath 2927 | index is re-calculated if necessary, in case any of the previously-yielded 2928 | notifications have been dismissed. 2929 | 2930 | Kwargs: 2931 | text: :py:class:`str` or :py:class:`Pattern` to match against the notification text. 2932 | Default: None. 2933 | t: :py:class:`str` or list/set/tuple of them, to match against the notification 2934 | type. Default: None. 2935 | partial: if True, then a partial (sub-string) text match will be performed. 2936 | Default: False. 2937 | inverse: if True, perform an inverse search. 2938 | Default: False. 2939 | index: The (0-based) index of the notification in the list to return. 2940 | Default: None. 2941 | """ 2942 | text = msg_filter.get("text", None) 2943 | t = msg_filter.get("t", None) 2944 | partial = msg_filter.get("partial", False) 2945 | inverse = msg_filter.get("inverse", False) 2946 | index = msg_filter.get("index", None) 2947 | 2948 | types = t if isinstance(t, (tuple, list, set, type(None))) else (t,) 2949 | op = not_ if inverse else bool 2950 | 2951 | # Log message describing the type of notification lookup. 2952 | if any((text, types, partial, inverse)): 2953 | log_msgs = [ 2954 | f"pattern: {text.pattern!r}" if isinstance(text, Pattern) else f"text: {text!r}", 2955 | f"type(s): {types!r}", 2956 | f"partial: {partial}", 2957 | f"inverse: {inverse}", 2958 | ] 2959 | log_msg = f"Performing match of notifications, {', '.join(log_msgs)}." 2960 | elif isinstance(index, int): 2961 | log_msg = f"Reading notification with index {index}." 2962 | else: 2963 | log_msg = "Reading all notifications." 2964 | 2965 | self.logger.info(log_msg) 2966 | 2967 | # Filter via index (starting from 0). 2968 | # Add 1 for the XPath index. 2969 | if isinstance(index, int): 2970 | start = index + 1 2971 | stop = start + 1 2972 | else: 2973 | start = 1 2974 | stop = self.msg_count + 1 2975 | 2976 | for i in range(start, stop): 2977 | if isinstance(index, int): 2978 | j = i 2979 | else: 2980 | new_stop = self.msg_count + 1 2981 | j = i - (stop - new_stop) 2982 | 2983 | msg = self.msg_class(self, index=j) 2984 | 2985 | if types and not op(msg.type in types): 2986 | continue 2987 | if isinstance(text, Pattern) and not op(text.match(msg.text)): 2988 | continue 2989 | if isinstance(text, str) and not op( 2990 | (partial and text in msg.text) or (not partial and text == msg.text) 2991 | ): 2992 | continue 2993 | 2994 | yield msg 2995 | 2996 | @retry_element 2997 | def read(self, **msg_filter): 2998 | """Return a list containing the notifications' text.""" 2999 | return [msg.text for msg in self.messages(**msg_filter)] 3000 | 3001 | @retry_element 3002 | def dismiss(self): 3003 | """Dismiss all notifications.""" 3004 | for msg in self.messages(): 3005 | msg.dismiss() 3006 | 3007 | def assert_no_error(self, ignore_messages=None): 3008 | """Assert no error messages present. 3009 | 3010 | Kwargs: 3011 | ignore_messages: :py:class:`list` of notification text to ignore. Default: None 3012 | """ 3013 | if ignore_messages is None: 3014 | ignore_messages = [] 3015 | msg_filter = {"t": {"success", "info", "warning"}, "inverse": True} 3016 | 3017 | self.logger.info("Asserting there are no error notifications.") 3018 | errs = self.read(**msg_filter) 3019 | if set(errs) - set(ignore_messages): 3020 | self.logger.error(errs) 3021 | raise AssertionError(f"assert_no_error: found error notifications {errs}") 3022 | 3023 | def assert_message(self, text, t=None, partial=False): 3024 | msg_filter = {"text": text, "t": t, "partial": partial} 3025 | all_msgs = self.read() 3026 | if not self.read(**msg_filter): 3027 | raise AssertionError( 3028 | "assert_message: failed to find matching notifications." 3029 | f" Available notifications: {all_msgs}" 3030 | ) 3031 | 3032 | def assert_success_message(self, text, t=None, partial=False): 3033 | self.assert_no_error() 3034 | self.assert_message(text, t=(t or "success"), partial=partial) 3035 | 3036 | @property 3037 | def is_displayed(self): 3038 | return self.parent_browser.is_displayed(self.ROOT) 3039 | -------------------------------------------------------------------------------- /src/widgetastic_patternfly/utils.py: -------------------------------------------------------------------------------- 1 | # module for patternfly utility classes and methods 2 | from enum import Enum 3 | 4 | 5 | class IconConstants(Enum): 6 | """class to hold just the icon constants 7 | 8 | References: 9 | https://www.patternfly.org/styles/icons/ 10 | """ 11 | 12 | ADD = "pficon-add-circle-o" 13 | ANGLE_DOWN = "fa-angle-down" 14 | ANGLE_LEFT = "fa-angle-left" 15 | ANGLE_RIGHT = "fa-angle-right" 16 | ANGLE_UP = "fa-angle-up" 17 | APPLICATIONS = "pficon-applications" 18 | ARROW = "pficon-arrow" 19 | CLUSTER = "pficon-cluster" 20 | CONTAINER_NODE = "pficon-container-node" 21 | CPU = "pficon-cpu" 22 | ERROR = "pficon-error-circle-o" 23 | HOME = "pficon-home" 24 | OK = "pficon-ok" 25 | WARNING = "pficon-warning-triangle-o" 26 | REFRESH = "fa-refresh" 27 | USER = "pficon-user" 28 | 29 | @classmethod 30 | def icon_enums(cls): 31 | return {a: s for a, s in vars(IconConstants).items() if isinstance(s, Enum)} 32 | 33 | 34 | class PFIcon: 35 | """Class to enumerate the patternfly default icons 36 | 37 | pficon-* markup classes have a variety of strings, this class should serve to prevent widget 38 | classes from having to parse, pass, or depend on string fragments 39 | """ 40 | 41 | icons = IconConstants 42 | 43 | @classmethod 44 | def icon_from_element(cls, element, browser): 45 | """Taking a webelement, scan its child element classes for pficon and fa, return icon state 46 | 47 | Args: 48 | element: webelement object that will be searched for pficon classes 49 | browser: browser instance to query child elements and classes 50 | 51 | Raises: 52 | widgetastic.exceptions.NoSuchElementException when no icon span found 53 | """ 54 | els = browser.elements( 55 | './/*[contains(@class, "pficon") or contains(@class, "fa")]', parent=element 56 | ) 57 | if len(els) != 1: 58 | return None # multiple icons 59 | 60 | icon_class = [ 61 | c for c in browser.classes(els.pop()) if c.startswith("pficon-") or c.startswith("fa-") 62 | ] 63 | # slice off first 6 chars if a class was found 64 | icon_name = icon_class.pop() if icon_class else None 65 | icons = [ 66 | getattr(cls.icons, attr, None) 67 | for attr, icon_string in cls.icons.icon_enums().items() 68 | if icon_string.value == icon_name 69 | ] 70 | return icons.pop() if icons else None 71 | -------------------------------------------------------------------------------- /testing/README.rst: -------------------------------------------------------------------------------- 1 | ================================ 2 | widgetastic.patternfly-Testing 3 | ================================ 4 | This project uses the same pattern that wt.core and wt.pf4 use for testing 5 | 6 | The pytest fixtures use podman directly to run: 7 | 1. selenium container 8 | 2. nginx container 9 | 10 | Selenium container is used as the webdriver. 11 | 12 | nginx container is used to host the testing page. 13 | 14 | In order to execute the tests, you have to do some setup for podman. 15 | 16 | Pull the selenium and nginx container images: 17 | ``` 18 | podman pull docker.io/library/nginx:alpine 19 | podman pull quay.io/redhatqe/selenium-standalone:latest 20 | ``` 21 | 22 | 23 | Create a directory for the podman socket, and start a listening service: 24 | ``` 25 | mkdir -p ${XDG_RUNTIME_DIR}/podman 26 | podman system service --time=0 unix://${XDG_RUNTIME_DIR}/podman/podman.sock & 27 | ``` 28 | -------------------------------------------------------------------------------- /testing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedHatQE/widgetastic.patternfly/272457dc64af132b8e066b6ad1346d8bde6f444b/testing/__init__.py -------------------------------------------------------------------------------- /testing/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from urllib.request import urlopen 3 | 4 | import allure # noreorder 5 | import pytest 6 | from podman import PodmanClient 7 | from selenium import webdriver 8 | from wait_for import wait_for 9 | from widgetastic.browser import Browser 10 | 11 | 12 | selenium_browser = None 13 | 14 | OPTIONS = {"firefox": webdriver.FirefoxOptions(), "chrome": webdriver.ChromeOptions()} 15 | 16 | # Begging, borrowing, and stealing from @quarkster 17 | # https://github.com/RedHatQE/widgetastic.patternfly4/blob/master/testing/conftest.py#L21 18 | 19 | 20 | def pytest_addoption(parser): 21 | parser.addoption( 22 | "--browser-name", 23 | help="Name of the browser, can also be set in env with BROWSER", 24 | choices=("firefox", "chrome"), 25 | default="firefox", 26 | ) 27 | 28 | 29 | @pytest.fixture(scope="session") 30 | def podman(): 31 | runtime_dir = os.getenv("XDG_RUNTIME_DIR") 32 | uri = f"unix://{runtime_dir}/podman/podman.sock" 33 | with PodmanClient(base_url=uri) as client: 34 | yield client 35 | 36 | 37 | @pytest.fixture(scope="session") 38 | def pod(podman, worker_id): 39 | last_oktet = 1 if worker_id == "master" else int(worker_id.lstrip("gw")) + 1 40 | localhost_for_worker = f"127.0.0.{last_oktet}" 41 | pod = podman.pods.create( 42 | f"widgetastic_testing_{last_oktet}", 43 | portmappings=[ 44 | {"host_ip": localhost_for_worker, "container_port": 5999, "host_port": 5999}, 45 | {"host_ip": localhost_for_worker, "container_port": 4444, "host_port": 4444}, 46 | {"host_ip": localhost_for_worker, "container_port": 80, "host_port": 8080}, 47 | ], 48 | ) 49 | pod.start() 50 | yield pod 51 | pod.remove(force=True) 52 | 53 | 54 | @pytest.fixture(scope="session") 55 | def browser_name(pytestconfig): 56 | return os.environ.get("BROWSER") or pytestconfig.getoption("--browser-name") 57 | 58 | 59 | @pytest.fixture(scope="session") 60 | def selenium_url(pytestconfig, worker_id, podman, pod): 61 | """Yields a command executor URL for selenium, and a port mapped for the test page to run on""" 62 | # use the worker id number from gw# to create hosts on loopback 63 | last_oktet = 1 if worker_id == "master" else int(worker_id.lstrip("gw")) + 1 64 | driver_url = f"http://127.0.0.{last_oktet}:4444" 65 | container = podman.containers.create( 66 | image="quay.io/redhatqe/selenium-standalone:latest", 67 | pod=pod.id, 68 | remove=True, 69 | name=f"selenium_{worker_id}", 70 | ) 71 | container.start() 72 | wait_for( 73 | urlopen, 74 | func_args=[driver_url], 75 | timeout=180, 76 | handle_exception=True, 77 | ) 78 | yield driver_url 79 | container.remove(force=True) 80 | 81 | 82 | @pytest.fixture(scope="session") 83 | def testing_page_url(worker_id, podman, pod): 84 | container = podman.containers.create( 85 | image="docker.io/library/nginx:alpine", 86 | pod=pod.id, 87 | remove=True, 88 | name=f"web_server_{worker_id}", 89 | mounts=[ 90 | { 91 | "source": f"{os.getcwd()}/testing", 92 | "target": "/usr/share/nginx/html", 93 | "type": "bind", 94 | } 95 | ], 96 | ) 97 | container.start() 98 | yield "http://localhost:80/testing_page.html" 99 | container.remove(force=True) 100 | 101 | 102 | @pytest.fixture(scope="session") 103 | def selenium_webdriver(browser_name, selenium_url, testing_page_url): 104 | driver = webdriver.Remote(command_executor=selenium_url, options=OPTIONS[browser_name.lower()]) 105 | driver.maximize_window() 106 | driver.get(testing_page_url) 107 | global selenium_browser 108 | selenium_browser = driver 109 | yield driver 110 | driver.quit() 111 | 112 | 113 | class CustomBrowser(Browser): 114 | @property 115 | def product_version(self): 116 | return "1.0.0" 117 | 118 | 119 | @pytest.fixture(scope="session") 120 | def custom_browser(selenium_webdriver): 121 | return CustomBrowser(selenium_webdriver) 122 | 123 | 124 | @pytest.fixture(scope="function") 125 | def browser(selenium_webdriver, custom_browser): 126 | yield custom_browser 127 | selenium_webdriver.refresh() 128 | 129 | 130 | def pytest_exception_interact(node, call, report): 131 | if selenium_browser is not None: 132 | allure.attach( 133 | selenium_browser.get_screenshot_as_png(), "Error screenshot", allure.attachment_type.PNG 134 | ) 135 | allure.attach(str(report.longrepr), "Error traceback", allure.attachment_type.TEXT) 136 | -------------------------------------------------------------------------------- /testing/test_about_modal.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from wait_for import wait_for 3 | from widgetastic.widget import View 4 | 5 | from widgetastic_patternfly import AboutModal 6 | from widgetastic_patternfly import Button 7 | 8 | # Values from testing_page.html modal 9 | modal_id = "about-modal" 10 | title = "Widgetastic About Modal" 11 | trademark = "Widget Trademark and Copyright Information" 12 | 13 | ITEMS = {"Field1": "Field1Value", "Field2": "Field2Value", "Field3": "Field3Value"} 14 | 15 | 16 | @pytest.mark.parametrize("locator", [modal_id, None], ids=["Modal_With_ID", "Modal_Without_ID"]) 17 | def test_modal_close(browser, locator): 18 | """ 19 | Test the about modal, including all methods/properties 20 | 21 | Test against modal defined in testing_page.html 22 | :param browser: browser fixture 23 | """ 24 | 25 | class TestView(View): 26 | """Dummy page matching testing_page.html elements""" 27 | 28 | button = Button("Launch about modal") 29 | about = AboutModal(id=locator) 30 | 31 | view = TestView(browser) 32 | assert not view.about.is_open 33 | 34 | # Open the modal 35 | assert view.button.title == "Launch Modal" 36 | assert not view.button.disabled 37 | view.button.click() 38 | 39 | view.flush_widget_cache() 40 | wait_for(lambda: view.about.is_open, delay=0.2, num_sec=10) 41 | 42 | assert view.about.title == title 43 | 44 | assert view.about.trademark == trademark 45 | 46 | assert view.about.items() == ITEMS 47 | 48 | # close the modal 49 | view.about.close() 50 | wait_for(lambda: not view.about.is_open, delay=0.1, num_sec=10) 51 | -------------------------------------------------------------------------------- /testing/test_agg_status_card.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from widgetastic.exceptions import LocatorNotImplemented 3 | from widgetastic.widget import View 4 | 5 | from widgetastic_patternfly import AggregateStatusCard 6 | from widgetastic_patternfly.utils import PFIcon 7 | 8 | 9 | def test_agg_status_card_read(browser): 10 | """Test Aggregate Status Card reading number, title, notifications""" 11 | 12 | class TestAggView(View): 13 | agg_card_ip = AggregateStatusCard(name="Ipsum", action_title="Add Ipsum") 14 | agg_card_amet = AggregateStatusCard(name="Amet") 15 | agg_card_adi = AggregateStatusCard(name="Adipiscing") 16 | 17 | view = TestAggView(browser) 18 | 19 | # Display 20 | for card in [view.agg_card_ip, view.agg_card_amet, view.agg_card_adi]: 21 | assert card.is_displayed 22 | 23 | # count 24 | assert view.agg_card_ip.count == 0 25 | assert view.agg_card_amet.count == 20 26 | assert view.agg_card_adi.count is None 27 | 28 | # Clickable body with title, or not 29 | assert view.agg_card_ip.click_body_action() is None 30 | for card in [view.agg_card_amet, view.agg_card_adi]: 31 | with pytest.raises(LocatorNotImplemented): 32 | card.click_body_action() 33 | 34 | # title icons 35 | for card in [view.agg_card_ip, view.agg_card_adi]: 36 | assert card.icon == PFIcon.icons.HOME 37 | assert view.agg_card_amet.icon is None 38 | 39 | # notifications 40 | ip_notes_expected = [{"icon": PFIcon.icons.ADD, "text": None}] 41 | ip_notes = view.agg_card_ip.notifications 42 | assert len(ip_notes) == 1 43 | assert [n.read() for n in ip_notes] == ip_notes_expected 44 | 45 | amet_notes_expected = [ 46 | {"icon": PFIcon.icons.ERROR, "text": "4"}, 47 | {"icon": PFIcon.icons.WARNING, "text": "1"}, 48 | ] 49 | amet_notes = view.agg_card_amet.notifications 50 | assert len(amet_notes) == 2 51 | assert [n.read() for n in amet_notes] == amet_notes_expected 52 | 53 | adi_notes_expected = [{"icon": None, "text": "noclick"}] 54 | adi_notes = view.agg_card_adi.notifications 55 | assert len(adi_notes) == 1 56 | assert [n.read() for n in adi_notes] == adi_notes_expected 57 | 58 | assert view.agg_card_ip.read() == { 59 | "icon": PFIcon.icons.HOME, 60 | "count": 0, 61 | "name": "Ipsum", 62 | "notifications": ip_notes_expected, 63 | } 64 | -------------------------------------------------------------------------------- /testing/test_all_components.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains code for auto-discovering Widget classes, instantiating 3 | them in a programatically defined view class as a new type, which is then 4 | instantiated. The widgets appear to get instantiated upon accessing them in the instantiated view. 5 | After all of this, the behavior of the widget objects can be tested. 6 | """ 7 | import inspect 8 | 9 | import pytest 10 | from widgetastic.utils import attributize_string 11 | from widgetastic.widget import View 12 | from widgetastic.widget import Widget 13 | 14 | import widgetastic_patternfly as wp 15 | 16 | 17 | collected_widgets = { 18 | name: t 19 | for name, t in inspect.getmembers(wp) 20 | if (inspect.isclass(t) and issubclass(t, Widget) and not issubclass(t, wp.ParametrizedView)) 21 | } 22 | """ Subclasses of Widget from the widgetastic_patternfly module (including 23 | it's own imports), that are gonna be tested. Not including the 24 | ParametrizedView. """ 25 | 26 | 27 | DUMMY_NAME = "name_of_the_dummy" 28 | DUMMY_ID = "id_of_the_dummy" 29 | DUMMY_LOCATOR = "/dummy" 30 | DUMMY_TREE_ID = "SOME_DUMMY_TREE_ID" 31 | 32 | 33 | # TODO generate from args spec that should by added to definition of the types. 34 | # Currently some parameters that are enough to successfully construct the 35 | # instance from the type are chosen. 36 | init_values = { 37 | wp.AggregateStatusCard: dict(name=DUMMY_NAME), 38 | wp.AggregateStatusMiniCard: dict(name=DUMMY_NAME, locator=DUMMY_LOCATOR), 39 | wp.BarChart: dict(id=DUMMY_ID), 40 | wp.BootstrapNav: dict(locator=DUMMY_LOCATOR), 41 | wp.BootstrapSelect: dict(locator=DUMMY_LOCATOR), 42 | wp.BootstrapSwitch: dict(id=DUMMY_ID), 43 | wp.DatePicker: dict(id=DUMMY_ID), 44 | wp.Dropdown: dict(text=DUMMY_NAME), 45 | wp.GroupedBarChart: dict(id=DUMMY_ID), 46 | wp.Kebab: dict(id=DUMMY_ID), 47 | wp.LineChart: dict(id=DUMMY_ID), 48 | wp.SelectorDropdown: dict(button_attr=DUMMY_LOCATOR, button_attr_value=DUMMY_NAME), 49 | wp.SingleLineChart: dict(id=DUMMY_ID), 50 | wp.SingleSplineChart: dict(id=DUMMY_ID), 51 | wp.SplineChart: dict(id=DUMMY_ID), 52 | wp.SparkLineChart: dict(id=DUMMY_ID), 53 | wp.StatusNotification: dict(note_element=DUMMY_LOCATOR), 54 | wp.Table: dict(locator=DUMMY_LOCATOR), 55 | wp.Text: dict(locator=DUMMY_LOCATOR), 56 | wp.ViewChangeButton: dict(title=DUMMY_NAME), 57 | wp.VerticalNavigation: dict(locator=DUMMY_LOCATOR), 58 | } 59 | """ Dicts with the values for __init__ methods of the `collected_widgets`. """ 60 | 61 | 62 | @pytest.fixture(scope="module") 63 | def test_view_class(): 64 | """Returns an subclass of View with the collected_widgets instantiated 65 | and assigned to it.""" 66 | 67 | # Instantiate objects to be set in the view with required params for __init__. 68 | attributes = { 69 | f"{attributize_string(name)}": cls(**init_values.get(cls, {})) 70 | for name, cls in collected_widgets.items() 71 | } 72 | 73 | # Required for the Tree widgets to function properly. 74 | attributes["tree_id"] = DUMMY_TREE_ID 75 | 76 | view_class = type("TheTestView", (View,), attributes) 77 | return view_class 78 | 79 | 80 | @pytest.fixture 81 | def test_view(browser, test_view_class): 82 | view = test_view_class(browser) 83 | return view 84 | 85 | 86 | @pytest.mark.parametrize("widget_name", collected_widgets.keys()) 87 | def test_widget_init(test_view, widget_name): 88 | """Test basic instantiation of the widgets in a view. 89 | 90 | When a View like this is defined: 91 | ``` 92 | class MyView(View): 93 | btn = Button(id="some_id") 94 | 95 | view = MyView(browser) 96 | ``` 97 | 98 | The Button.__init__ seem to be delayed until the view.btn is accessed. 99 | We got the view as the test_view fixture, so we now need to access it to 100 | check it won't produce an exception.""" 101 | 102 | assert getattr(test_view, attributize_string(widget_name)) 103 | 104 | 105 | @pytest.mark.parametrize("widget_name", collected_widgets.keys()) 106 | def test_widget_stringification(test_view, widget_name): 107 | """Tests whether the widget can be stringified. 108 | All the widgets that can be instantiated should be able to stringify.""" 109 | wgt = getattr(test_view, attributize_string(widget_name)) 110 | assert isinstance(str(wgt), str) 111 | assert isinstance(repr(wgt), str) 112 | -------------------------------------------------------------------------------- /testing/test_bootstrap_nav.py: -------------------------------------------------------------------------------- 1 | from widgetastic.utils import partial_match 2 | from widgetastic.widget import View 3 | 4 | from widgetastic_patternfly import BootstrapNav 5 | 6 | 7 | def test_bootstrap_nav(browser): 8 | class TestView(View): 9 | nav = BootstrapNav('.//div/ul[@class="nav nav-pills nav-stacked"]') 10 | 11 | view = TestView(browser) 12 | 13 | # assert that nav is visible 14 | assert view.nav.is_displayed 15 | # Check if all options are being returned 16 | assert view.nav.all_options == [ 17 | "ALL (Default)", 18 | "Environment / Dev", 19 | "Environment / Prod", 20 | "UAT", 21 | "Environment / UAT", 22 | "", 23 | ] 24 | # assert if currently active(selected) element is being returned correctly 25 | assert view.nav.read() == ["ALL (Default)"] 26 | # assert if list has_item 27 | assert view.nav.has_item(text="Environment / Prod") 28 | # assert if partial match works for is_disabled 29 | assert view.nav.is_disabled(text=partial_match("UAT")) 30 | -------------------------------------------------------------------------------- /testing/test_bootstrap_tree.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import View 2 | 3 | from widgetastic_patternfly import BootstrapTreeview 4 | 5 | 6 | def test_bootstrap_tree(browser): 7 | class TestView(View): 8 | tree = BootstrapTreeview(tree_id="treeview1") 9 | 10 | view = TestView(browser) 11 | 12 | # assert that tree is visible 13 | assert view.tree.is_displayed 14 | # assert that we have multiple roots in this tree view 15 | assert view.tree.root_item_count > 1 16 | # assert that we have multiple root items 17 | assert len(view.tree.root_items) > 1 18 | # TODO: add more to the widget in testing page so that we can test more complex 19 | # functionality, such as currently_selected 20 | -------------------------------------------------------------------------------- /testing/test_buttons.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import View 2 | 3 | from widgetastic_patternfly import Button 4 | 5 | 6 | def test_button_click(browser): 7 | class TestView(View): 8 | any_button = Button() # will pick up first button, basically. 'Default Normal' right now 9 | button1 = Button("Default Normal") 10 | button2 = Button(title="Destructive title") 11 | button3 = Button(title="noText", classes=[Button.PRIMARY]) 12 | 13 | view = TestView(browser) 14 | assert view.any_button.is_displayed 15 | assert view.any_button.text == "Default Normal" 16 | assert view.any_button.read() == "Default Normal" 17 | assert view.button1.is_displayed 18 | assert view.button1.read() == "Default Normal" 19 | assert view.button2.is_displayed 20 | assert view.button2.read() == "Destructive" 21 | assert view.button2.title == "Destructive title" 22 | assert view.button3.is_displayed 23 | assert view.button3.read() == "" 24 | assert view.button3.title == "noText" 25 | 26 | FILL_DICT = {"any_button": True, "button1": True, "button2": False, "button3": False} 27 | assert view.fill(FILL_DICT) 28 | -------------------------------------------------------------------------------- /testing/test_data_visualization.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from widgetastic.widget import View 3 | 4 | from widgetastic_patternfly import BarChart 5 | from widgetastic_patternfly import GroupedBarChart 6 | from widgetastic_patternfly import LineChart 7 | from widgetastic_patternfly import SingleLineChart 8 | from widgetastic_patternfly import SingleSplineChart 9 | from widgetastic_patternfly import SparkLineChart 10 | from widgetastic_patternfly import SplineChart 11 | 12 | SPARK_DATA = [10, 14, 12, 20, 31, 27, 44, 36, 52, 55, 62, 68, 69, 88, 74, 88, 91] 13 | 14 | LINE_DATA = { 15 | "0": {"data5": "90", "data3": "70", "data2": "50", "data1": "30", "data4": "10"}, 16 | "1": {"data4": "340", "data2": "220", "data1": "200", "data5": "150", "data3": "100"}, 17 | "2": {"data3": "390", "data2": "310", "data5": "160", "data1": "100", "data4": "30"}, 18 | "3": {"data1": "400", "data3": "295", "data4": "290", "data2": "240", "data5": "165"}, 19 | "4": {"data5": "180", "data3": "170", "data1": "150", "data2": "115", "data4": "35"}, 20 | "5": {"data1": "250", "data3": "220", "data2": "25", "data4": "20", "data5": "5"}, 21 | } 22 | 23 | BAR_DATA = { 24 | "2013": {"Q1": "400", "Q2": "355", "Q3": "315", "Q4": "180"}, 25 | "2014": {"Q1": "250", "Q2": "305", "Q3": "340", "Q4": "390"}, 26 | "2015": {"Q1": "375", "Q2": "300", "Q3": "276", "Q4": "190"}, 27 | } 28 | 29 | LINE_DATA_1 = {"0": "30", "1": "200", "2": "100", "3": "400", "4": "150", "5": "250"} 30 | 31 | BAR_DATA_1 = {"Q1": "400", "Q2": "360", "Q3": "320", "Q4": "175"} 32 | 33 | LINE_DATA_4 = { 34 | "0": {"data4": "10"}, 35 | "1": {"data4": "340"}, 36 | "2": {"data4": "30"}, 37 | "3": {"data4": "290"}, 38 | "4": {"data4": "35"}, 39 | "5": {"data4": "20"}, 40 | } 41 | 42 | BAR_DATA_Q4 = {"2013": {"Q4": "180"}, "2014": {"Q4": "390"}, "2015": {"Q4": "190"}} 43 | 44 | LINE_LEGENDS = ["data1", "data2", "data3", "data4", "data5"] 45 | 46 | BAR_LEGENDS = ["Q1", "Q3", "Q2", "Q4"] 47 | 48 | 49 | class DataVisualization(View): 50 | spark = SparkLineChart(id="sparklineChart") 51 | 52 | single_line = SingleLineChart(id="singleLineChart") 53 | line = LineChart(id="lineChart") 54 | 55 | single_spline = SingleSplineChart(id="singleSplineChart") 56 | spline = SplineChart(id="splineChart") 57 | 58 | vertical_bar_chart = BarChart(id="verticalBarChart") 59 | gp_vertical_bar_chart = GroupedBarChart(id="groupedVerticalBarChart") 60 | stack_vertical_bar_chart = GroupedBarChart(id="stackedVerticalBarChart") 61 | 62 | horizontal_bar_chart = BarChart(id="horizontalBarChart") 63 | gp_horizontal_bar_chart = GroupedBarChart(id="groupedHorizontalBarChart") 64 | stack_horizontal_bar_chart = GroupedBarChart(id="stackedHorizontalBarChart") 65 | 66 | 67 | def test_spark_line_chart(browser): 68 | view = DataVisualization(browser) 69 | 70 | assert view.spark.is_displayed 71 | data = view.spark.read() 72 | data_without_unit = [int(d.split("%")[0]) for d in data] 73 | assert data_without_unit == SPARK_DATA 74 | 75 | 76 | @pytest.mark.parametrize( 77 | "graph", ["single_line", "single_spline", "vertical_bar_chart", "horizontal_bar_chart"] 78 | ) 79 | def test_single_legend_charts(browser, graph): 80 | view = DataVisualization(browser) 81 | chart = getattr(view, graph) 82 | data = LINE_DATA_1 if "line" in graph else BAR_DATA_1 83 | 84 | assert chart.is_displayed 85 | 86 | # read overall chart 87 | assert chart.read() == data 88 | 89 | # check for x axis values 90 | for x_point, value in data.items(): 91 | assert chart.get_values(x_point)[x_point] == value 92 | 93 | 94 | @pytest.mark.parametrize( 95 | "graph", 96 | [ 97 | "line", 98 | "spline", 99 | "gp_vertical_bar_chart", 100 | "stack_vertical_bar_chart", 101 | "gp_horizontal_bar_chart", 102 | "stack_horizontal_bar_chart", 103 | ], 104 | ) 105 | def test_multi_legend_charts(browser, graph): 106 | view = DataVisualization(browser) 107 | chart = getattr(view, graph) 108 | 109 | data = LINE_DATA if "line" in graph else BAR_DATA 110 | leg_data = LINE_DATA_4 if "line" in graph else BAR_DATA_Q4 111 | legs = LINE_LEGENDS if "line" in graph else BAR_LEGENDS 112 | fouth_leg = "data4" if "line" in graph else "Q4" 113 | 114 | assert chart.is_displayed 115 | # read full chart 116 | assert chart.read() == data 117 | 118 | # check data as per x axis value 119 | for x_point, value in data.items(): 120 | assert chart.get_values(x_point)[x_point] == value 121 | 122 | # check all legends on chart 123 | assert set(chart.legends) == set(legs) 124 | 125 | # check legends hide and display properties 126 | chart.display_all_legends() 127 | for leg in legs: 128 | chart.hide_legends(leg) 129 | assert not chart.legend_is_displayed(leg) 130 | 131 | chart.hide_all_legends() 132 | for leg in legs: 133 | chart.display_legends(leg) 134 | assert chart.legend_is_displayed(leg) 135 | 136 | # check data for particular legends 137 | assert chart.get_data_for_legends(fouth_leg) == leg_data 138 | assert chart.get_data_for_legends(*chart.legends) == data 139 | -------------------------------------------------------------------------------- /testing/test_date_picker.py: -------------------------------------------------------------------------------- 1 | import random 2 | from datetime import datetime 3 | from datetime import timedelta 4 | 5 | from widgetastic.widget import View 6 | 7 | from widgetastic_patternfly import DatePicker 8 | 9 | 10 | def test_bootstrap_date_picker(browser): 11 | class TestView(View): 12 | dp_readwrite = DatePicker(name="date_readwrite") 13 | dp_readonly = DatePicker(id="date_readonly") 14 | dp_basic_patternfly = DatePicker(id="patternfly_dp") 15 | 16 | view = TestView(browser) 17 | 18 | # `readonly` property working or not 19 | assert view.dp_readonly.readonly 20 | assert not view.dp_readwrite.readonly 21 | 22 | # `fill` and `read` with current date 23 | today_date = datetime.now() 24 | view.dp_readonly.fill(today_date) 25 | assert today_date.date() == view.dp_readonly.read().date() 26 | view.dp_readwrite.fill(today_date) 27 | assert today_date.date() == view.dp_readwrite.read().date() 28 | view.dp_basic_patternfly.fill(today_date) 29 | assert today_date.date() == view.dp_basic_patternfly.read().date() 30 | 31 | # check `fill` and `read` with year back date 32 | yr_back_date = today_date - timedelta(days=365) 33 | view.dp_readonly.fill(yr_back_date) 34 | assert yr_back_date.date() == view.dp_readonly.read().date() 35 | view.dp_readwrite.fill(yr_back_date) 36 | assert yr_back_date.date() == view.dp_readwrite.read().date() 37 | view.dp_basic_patternfly.fill(yr_back_date) 38 | assert yr_back_date.date() == view.dp_basic_patternfly.read().date() 39 | 40 | # check if date already selected `fill` should return False 41 | view.dp_readonly.fill(datetime.now()) 42 | assert not view.dp_readonly.fill(datetime.now()) 43 | view.dp_readwrite.fill(datetime.now()) 44 | assert not view.dp_readwrite.fill(datetime.now()) 45 | view.dp_basic_patternfly.fill(datetime.now()) 46 | assert not view.dp_basic_patternfly.fill(datetime.now()) 47 | 48 | # `fill` and `read` with random timedelta 49 | random_weeks = random.randint(100, 5000) 50 | date_obj = today_date - timedelta(weeks=random_weeks) 51 | 52 | view.dp_readonly.fill(date_obj) 53 | assert date_obj.date() == view.dp_readonly.read().date() 54 | view.dp_readwrite.fill(date_obj) 55 | assert date_obj.date() == view.dp_readwrite.read().date() 56 | view.dp_basic_patternfly.fill(date_obj) 57 | assert date_obj.date() == view.dp_basic_patternfly.read().date() 58 | -------------------------------------------------------------------------------- /testing/test_dropdowns.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Text 2 | from widgetastic.widget import View 3 | 4 | from widgetastic_patternfly import Kebab 5 | 6 | 7 | def test_kebab(browser): 8 | class TestView(View): 9 | kebab_menu = Kebab(id="dropdownKebab") 10 | kebab_output = Text(locator='//*[@id="kebab_display"]') 11 | 12 | view = TestView(browser) 13 | 14 | # check for display 15 | assert view.kebab_menu.is_displayed 16 | 17 | # check dropdown open/close methods 18 | assert not view.kebab_menu.is_opened 19 | view.kebab_menu.open() 20 | assert view.kebab_menu.is_opened 21 | view.kebab_menu.close() 22 | assert not view.kebab_menu.is_opened 23 | 24 | # check for items 25 | assert view.kebab_menu.items == ["Action one", "Another action", "Separated link"] 26 | assert view.kebab_menu.has_item("Another action") 27 | assert not view.kebab_menu.has_item("kebab") 28 | 29 | # check selection 30 | for item in view.kebab_menu.items: 31 | view.kebab_menu.item_select(item) 32 | # closes by default after selection 33 | assert not view.kebab_menu.is_opened 34 | assert item == view.kebab_output.read() 35 | -------------------------------------------------------------------------------- /testing/test_flashmessages.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import namedtuple 3 | 4 | from widgetastic.widget import View 5 | 6 | from widgetastic_patternfly import FlashMessages 7 | 8 | Message = namedtuple("Message", "text type") 9 | 10 | OK_MSGS = [ 11 | Message("Retirement date set to 12/31/19 15:55 UTC", "success"), 12 | Message("Retirement date removed", "success"), 13 | Message("All changes have been reset", "warning"), 14 | Message("Set/remove retirement date was cancelled by the user", "success"), 15 | Message("Retirement initiated for 1 VM and Instance from the CFME Database", "success"), 16 | ] 17 | ERROR_MSGS = [Message("Not Configured", "error")] 18 | MSGS = OK_MSGS + ERROR_MSGS 19 | 20 | 21 | def test_flashmessage(browser): 22 | class TestView(View): 23 | flash = View.nested(FlashMessages) 24 | 25 | view = TestView(browser) 26 | msgs = view.flash.read() 27 | assert len(msgs) == len(MSGS) 28 | for msg, MSG in zip(msgs, MSGS): 29 | assert msg == MSG.text 30 | 31 | # Verify assert_no_error() with ignore_messages, then dismiss the error messages 32 | view.flash.assert_no_error(ignore_messages=[msg.text for msg in ERROR_MSGS]) 33 | for msg in view.flash.messages(): 34 | if msg.type == "error": 35 | msg.dismiss() 36 | 37 | # Verify assert_no_error() 38 | view.flash.assert_no_error() 39 | 40 | # Test regex match. 41 | t = re.compile("^Retirement") 42 | view.flash.assert_message(t) 43 | view.flash.assert_success_message(t) 44 | 45 | # Test partial pattern match. 46 | t = "etirement" 47 | view.flash.assert_message(t, partial=True) 48 | view.flash.assert_success_message(t, partial=True) 49 | 50 | # Test inverse pattern match. 51 | t = "This message does not exist" 52 | assert view.flash.read(text=t, inverse=True) == [msg.text for msg in OK_MSGS] 53 | 54 | view.flash.dismiss() 55 | assert not view.flash.read() 56 | -------------------------------------------------------------------------------- /testing/test_modal.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | 3 | import pytest 4 | from wait_for import wait_for 5 | from widgetastic.widget import TextInput 6 | from widgetastic.widget import View 7 | 8 | from widgetastic_patternfly import Button 9 | from widgetastic_patternfly import Modal 10 | 11 | # Values from testing_page.html modal 12 | modal_id = "myModal" 13 | title = "Modal Title" 14 | 15 | 16 | MODAL_INTERACTION_TIMEOUT = 5 17 | 18 | 19 | def workaround_modal_close_n_open_timing_issue(): 20 | sleep(0.1) 21 | 22 | 23 | class SpecificModal(Modal): 24 | """Specific Modal class overwrites the body of Modal, since the form will vary.""" 25 | 26 | @View.nested 27 | class body(View): # noqa 28 | field_one = TextInput(id="textInput-modal-markup") 29 | field_two = TextInput(id="textInput2-modal-markup") 30 | field_three = TextInput(id="textInput3-modal-markup") 31 | 32 | 33 | @pytest.mark.skip(reason="https://github.com/RedHatQE/widgetastic.patternfly/issues/126") 34 | def test_generic_modal(browser): 35 | """ 36 | Test the modal, including all methods/properties 37 | 38 | Test against modal defined in testing_page.html 39 | :param browser: browser fixture 40 | """ 41 | 42 | class TestView(View): 43 | """Dummy page matching testing_page.html elements""" 44 | 45 | button = Button("Launch demo modal") 46 | modal = SpecificModal(id=modal_id) 47 | 48 | view = TestView(browser) 49 | assert not view.modal.is_displayed 50 | 51 | # Open the modal 52 | assert not view.button.disabled 53 | view.button.click() 54 | wait_for(lambda: view.modal.is_displayed, delay=0.5, num_sec=MODAL_INTERACTION_TIMEOUT) 55 | 56 | assert view.modal.title == title 57 | 58 | # close the modal via the "x" 59 | view.modal.close() 60 | view.flush_widget_cache() 61 | wait_for(lambda: not view.modal.is_displayed, delay=0.5, num_sec=MODAL_INTERACTION_TIMEOUT) 62 | 63 | workaround_modal_close_n_open_timing_issue() 64 | 65 | # open modal again 66 | view.button.click() 67 | wait_for(lambda: view.modal.is_displayed, delay=0.5, num_sec=MODAL_INTERACTION_TIMEOUT) 68 | # make sure buttons are not disabled 69 | assert not view.modal.footer.dismiss.disabled 70 | assert not view.modal.footer.accept.disabled 71 | # make sure the cancel button works 72 | view.modal.dismiss() 73 | wait_for(lambda: not view.modal.is_displayed, delay=0.1, num_sec=MODAL_INTERACTION_TIMEOUT) 74 | 75 | workaround_modal_close_n_open_timing_issue() 76 | # open modal to fill the form 77 | view.button.click() 78 | wait_for(lambda: view.modal.is_displayed, delay=0.5, num_sec=5) 79 | assert view.fill( 80 | {"modal": {"body": {"field_one": "value1", "field_two": "value2", "field_three": "value3"}}} 81 | ) 82 | # make sure accept button works 83 | view.modal.accept() 84 | wait_for(lambda: not view.modal.is_displayed, delay=0.1, num_sec=MODAL_INTERACTION_TIMEOUT) 85 | -------------------------------------------------------------------------------- /testing/testing_page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | Patternfly testing page 29 | 30 | 31 |
    32 |

    33 | 34 | 35 | 36 | 37 |

    38 |

    39 | 40 | 41 | 42 |

    43 |

    44 | 45 | 46 | 47 |

    48 | 49 | 50 | 77 | 78 | 79 | 80 |
    81 |
    82 | 85 | 86 |
    87 | 88 |
    89 | 92 | 93 |
    94 | 95 |
    96 | 99 | 100 |
    101 |
    102 | 103 | 129 | 130 | 131 | 132 | 134 |
    135 |
    136 |
    137 |
    138 |
    139 |

    140 | 0 Ipsum 141 |

    142 |
    143 |

    144 | 145 |

    146 |
    147 |
    148 |
    149 |
    150 |
    151 |

    152 | 20 Amet 153 |

    154 |
    155 |

    156 | 4 157 | 1 158 |

    159 |
    160 |
    161 |
    162 |
    163 |
    164 |

    165 | Adipiscing 166 |

    167 |
    168 |

    169 | noclick 170 |

    171 |
    172 |
    173 |
    174 |
    175 |
    176 | 177 | 188 |
    189 | 190 |
    191 |
    192 |
    193 |
    194 |
    195 |

    196 | 197 | 0 Ipsum 198 |

    199 |
    200 |

    201 | 202 |

    203 |
    204 |
    205 |
    206 |
    207 |
    208 |

    209 | 210 | 211 | 20 Amet 212 | 213 |

    214 |
    215 |

    216 | 4 217 |

    218 |
    219 |
    220 |
    221 |
    222 |
    223 |

    224 | 225 | 226 | 9 Adipiscing 227 | 228 |

    229 |
    230 |

    231 | 232 |

    233 |
    234 |
    235 |
    236 |
    237 |
    238 | 239 | 250 |
    251 | 252 | 253 | 265 | 266 | 271 | 272 | 273 |
    274 |
    275 | 276 | 342 |
    343 | 344 | 345 | 346 |
    347 | 350 | 351 |

    Sparkline

    352 |
    353 |
    354 |
    355 |
    Less than one year remaining
    356 | 368 |
    369 |
    370 | 371 |

    Line Chart

    372 |
    373 |
    374 |
    375 | 393 |
    394 |
    395 | 396 |

    Single Line Chart

    397 |
    398 |
    399 |
    400 | 413 |
    414 |
    415 |
    416 | 417 | 418 |
    419 | 422 | 423 |

    Spline Chart

    424 |
    425 |
    426 |
    427 | 444 |
    445 |
    446 | 447 |

    Single Spline Chart

    448 |
    449 |
    450 |
    451 | 464 |
    465 |
    466 |
    467 | 468 | 469 |
    470 | 473 | 474 |

    Vertical Bar Chart

    475 |
    476 |
    477 |
    478 | 502 |
    503 |
    504 | 505 | 506 |

    Grouped Vertical Bar Chart

    507 |
    508 |
    509 |
    510 | 550 |
    551 |
    552 | 553 | 554 |

    Stacked Vertical Bar Chart

    555 |
    556 |
    557 |
    558 | 597 | 598 |
    599 |
    600 | 601 |

    Horizontal Bar Chart

    602 |
    603 |
    604 |
    605 | 630 |
    631 |
    632 | 633 |

    Grouped Horizontal Bar Chart

    634 |
    635 |
    636 |
    637 | 678 |
    679 |
    680 | 681 |

    Stacked Horizontal Bar Chart

    682 |
    683 |
    684 |
    685 | 725 | 726 |
    727 |
    728 |
    729 | 730 | 731 | 767 | 768 |
    769 | 774 |
    775 |
    776 | 796 |
    797 | 798 | 799 |
    800 |
    801 |
    802 | 805 | 806 | Retirement date set to 12/31/19 15:55 UTC 807 |
    808 |
    809 | 812 | 813 | Retirement date removed 814 |
    815 |
    816 | 819 | 820 | All changes have been reset 821 |
    822 |
    823 | 826 | 827 | Set/remove retirement date was cancelled by the user 828 |
    829 |
    830 | 833 | 834 | Retirement initiated for 1 VM and Instance from the CFME Database 835 |
    836 |
    837 | 840 | 841 | Not Configured 842 |
    843 |
    844 |
    845 | 846 | 847 | 848 | --------------------------------------------------------------------------------