├── CLAUDE.md ├── tests ├── __init__.py └── conftest.py ├── airgun ├── entities │ ├── __init__.py │ ├── rhai │ │ ├── __init__.py │ │ ├── overview.py │ │ ├── rule.py │ │ ├── action.py │ │ ├── base.py │ │ ├── manage.py │ │ └── inventory.py │ ├── about.py │ ├── fact_value.py │ ├── global_parameter.py │ ├── upgrade.py │ ├── audit.py │ ├── login.py │ ├── bootc.py │ ├── eol_banner.py │ ├── sync_templates.py │ ├── rhsso_login.py │ ├── settings.py │ ├── dashboard.py │ ├── file.py │ ├── containerimagetag.py │ ├── smart_class_parameter.py │ ├── config_report.py │ ├── base.py │ ├── sync_status.py │ ├── ansible_variable.py │ ├── media.py │ ├── subnet.py │ ├── puppet_class.py │ ├── modulestream.py │ ├── task.py │ ├── oscapreport.py │ ├── configgroup.py │ ├── architecture.py │ ├── ansible_role.py │ ├── os.py │ ├── bookmark.py │ ├── package.py │ ├── cloud_vulnerabilities.py │ ├── hardware_model.py │ ├── user.py │ ├── usergroup.py │ ├── role.py │ └── contentcredential.py ├── helpers │ ├── __init__.py │ ├── host.py │ └── base.py ├── views │ ├── __init__.py │ ├── about.py │ ├── global_parameter.py │ ├── upgrade.py │ ├── dynflowconsole.py │ ├── login.py │ ├── fact.py │ ├── settings.py │ ├── config_report.py │ ├── bootc.py │ ├── eol_banner.py │ ├── rhsso_login.py │ ├── bookmark.py │ ├── file.py │ ├── modulestream.py │ ├── audit.py │ ├── configgroup.py │ ├── architecture.py │ ├── role.py │ ├── media.py │ ├── containerimagetag.py │ ├── hardware_model.py │ ├── ansible_role.py │ ├── puppet_class.py │ ├── oscapcontent.py │ ├── domain.py │ ├── computeprofile.py │ ├── filter.py │ ├── contentcredential.py │ ├── http_proxy.py │ ├── oscaptailoringfile.py │ ├── discoveryrule.py │ ├── syncplan.py │ ├── user.py │ ├── sync_templates.py │ ├── usergroup.py │ ├── partitiontable.py │ ├── oscapreport.py │ ├── cloud_vulnerabilities.py │ ├── package.py │ ├── puppet_environment.py │ └── subnet.py ├── __init__.py ├── exceptions.py ├── fixtures.py ├── settings.py └── utils.py ├── requirements.txt ├── .github ├── auto_assign.yml ├── workflows │ ├── required_labels.yml │ ├── auto_assignment.yaml │ ├── trigger_robottelo_workflow.yml │ ├── dispatch_release.yml │ ├── prt_labels.yml │ ├── pull_request.yml │ ├── auto_cherry_pick_merge.yaml │ └── prt_result.yml └── dependabot.yml ├── .flake8 ├── requirements-optional.txt ├── docs ├── examples.rst ├── spelling_wordlist.txt ├── build-docs.rst └── conf.py ├── .gitignore ├── .pre-commit-config.yaml ├── README.rst ├── catalog-info.yaml ├── settings.ini.example ├── Makefile └── setup.py /CLAUDE.md: -------------------------------------------------------------------------------- 1 | AGENTS.md -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /airgun/entities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /airgun/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /airgun/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /airgun/entities/rhai/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | 2 | --editable . 3 | -------------------------------------------------------------------------------- /.github/auto_assign.yml: -------------------------------------------------------------------------------- 1 | addAssignees: author 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 99 3 | 4 | exclude = .git, __pycache__, build, dist 5 | -------------------------------------------------------------------------------- /requirements-optional.txt: -------------------------------------------------------------------------------- 1 | pre-commit 2 | sphinx 3 | sphinx-autoapi 4 | sphinxcontrib-spelling 5 | 6 | # For linting 7 | ruff 8 | 9 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | For usage examples see Robottelo's `ui `_ tests. 5 | -------------------------------------------------------------------------------- /airgun/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from airgun.settings import Settings 4 | 5 | settings = Settings() 6 | 7 | ERRATA_REGEXP = re.compile(r'\w{3,4}[:-]\d{4}[-:]\d{1,4}') 8 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from airgun import settings 2 | 3 | pytest_plugins = ['airgun.fixtures'] 4 | 5 | 6 | def pytest_collection_modifyitems(): 7 | """called after collection has been performed, may filter or re-order 8 | the items in-place. 9 | """ 10 | if not settings.configured: 11 | settings.configure() 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Distribution / packaging 2 | /airgun.egg-info 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | .cache/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # User's configuration 11 | settings.ini 12 | 13 | # Generated documentation 14 | /docs/_build/ 15 | /docs/autoapi/ 16 | 17 | # common venv name 18 | .airgun/ 19 | venv* 20 | 21 | -------------------------------------------------------------------------------- /docs/spelling_wordlist.txt: -------------------------------------------------------------------------------- 1 | abalakh 2 | AirGun 3 | boolean 4 | cetera 5 | checkbox 6 | checkboxes 7 | et 8 | init 9 | javascript 10 | jQuery 11 | Katello 12 | lifecycle 13 | locator 14 | multiselect 15 | Patternfly 16 | prequisite 17 | robottelo 18 | Robottelo 19 | saucelabs 20 | screenshot 21 | Subnet 22 | url 23 | versa 24 | webdriver 25 | widgetastic 26 | XPaths 27 | -------------------------------------------------------------------------------- /docs/build-docs.rst: -------------------------------------------------------------------------------- 1 | Building documentation locally 2 | ============================== 3 | 4 | To build documentation locally, use: 5 | 6 | .. code-block:: bash 7 | 8 | pip install -r requirements-optional.txt 9 | make docs-html 10 | 11 | You might also try to run spell check: 12 | 13 | .. code-block:: bash 14 | 15 | pip install -r requirements-optional.txt 16 | make docs-spelling 17 | -------------------------------------------------------------------------------- /airgun/views/about.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Text 2 | 3 | from airgun.views.common import BaseLoggedInView, SearchableViewMixinPF4 4 | 5 | 6 | class AboutView(BaseLoggedInView, SearchableViewMixinPF4): 7 | title = Text("//h1[normalize-space(.)='About']") 8 | 9 | @property 10 | def is_displayed(self): 11 | return self.browser.wait_for_element(self.title, exception=False) is not None 12 | -------------------------------------------------------------------------------- /airgun/views/global_parameter.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Text 2 | 3 | from airgun.views.common import BaseLoggedInView, SearchableViewMixinPF4 4 | 5 | 6 | class GlobalParameterView(BaseLoggedInView, SearchableViewMixinPF4): 7 | title = Text("//h1[normalize-space(.)='Global Parameters']") 8 | 9 | @property 10 | def is_displayed(self): 11 | return self.browser.wait_for_element(self.title, exception=False) is not None 12 | -------------------------------------------------------------------------------- /airgun/views/upgrade.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Text 2 | 3 | from airgun.views.common import BaseLoggedInView 4 | 5 | 6 | class UpgradeView(BaseLoggedInView): 7 | title = Text("//h1[normalize-space(.)='Satellite upgrade']") 8 | new = Text("//a[contains(@href, 'documentation')]") 9 | 10 | @property 11 | def is_displayed(self): 12 | return self.browser.wait_for_element(self.title, exception=False) is not None 13 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # configuration for pre-commit git hooks 2 | 3 | ci: 4 | autofix_prs: false # disable autofixing PRs 5 | 6 | repos: 7 | - repo: https://github.com/astral-sh/ruff-pre-commit 8 | rev: v0.14.8 9 | hooks: 10 | - id: ruff-check 11 | args: [--fix, --exit-non-zero-on-fix] 12 | - id: ruff-format 13 | - repo: https://github.com/pre-commit/pre-commit-hooks 14 | rev: v6.0.0 15 | hooks: 16 | - id: check-yaml 17 | - id: debug-statements 18 | -------------------------------------------------------------------------------- /.github/workflows/required_labels.yml: -------------------------------------------------------------------------------- 1 | # CI jobs to check specific labels present / absent 2 | name: required_labels 3 | 4 | on: 5 | pull_request: 6 | types: [opened, labeled, unlabeled, synchronize] 7 | 8 | jobs: 9 | cherrypick_label: 10 | name: Enforcing cherrypick labels 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: mheap/github-action-required-labels@v5 14 | with: 15 | mode: exactly 16 | count: 1 17 | labels: "CherryPick, No-CherryPick" 18 | -------------------------------------------------------------------------------- /airgun/helpers/host.py: -------------------------------------------------------------------------------- 1 | from airgun.helpers.base import BaseEntityHelper 2 | 3 | 4 | class HostHelper(BaseEntityHelper): 5 | def read_create_view(self, values, read_widget_names=None): 6 | """Open create host view, fill entity fields with supplied values, and then return widgets 7 | values. Read values from 'read_widget_names' list if provided otherwise read values from 8 | all view widgets. 9 | """ 10 | return self.read_filled_view('New', values=values, read_widget_names=read_widget_names) 11 | -------------------------------------------------------------------------------- /airgun/views/dynflowconsole.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Text 2 | from widgetastic_patternfly4 import Pagination as PF4Pagination 3 | 4 | from airgun.views.common import BaseLoggedInView 5 | 6 | 7 | class DynflowConsoleView(BaseLoggedInView): 8 | title = Text("//a[@class='navbar-brand']//img") 9 | output = Text("//div[@class='action']/pre[2]") 10 | 11 | pagination = PF4Pagination() 12 | 13 | @property 14 | def is_displayed(self): 15 | return self.browser.wait_for_element(self.title, exception=False) is not None 16 | -------------------------------------------------------------------------------- /airgun/views/login.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import ClickableMixin, Text, TextInput, View 2 | 3 | 4 | class LoginView(View, ClickableMixin): 5 | username = TextInput(id='login_login') 6 | password = TextInput(id='login_password') 7 | login_text = Text(".//footer[contains(@class,'pf-v5-c-login__footer')]") 8 | logo = Text('//img[@class="pf-v5-c-brand"]') 9 | submit = Text('//button[@type="submit"]') 10 | 11 | @property 12 | def is_displayed(self): 13 | return self.browser.wait_for_element(self.username, exception=False) is not None 14 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://travis-ci.org/SatelliteQE/airgun.svg 2 | :scale: 50 % 3 | :alt: Build Status 4 | :align: left 5 | :target: https://travis-ci.org/SatelliteQE/airgun 6 | 7 | 8 | Airgun 9 | ====== 10 | 11 | Airgun is a Python library which is build over `Widgetastic`_ and `navmazing`_ 12 | to make Satellite 6 UI testing easier. 13 | 14 | For more info please see `documentation`_. 15 | 16 | .. _Widgetastic: https://github.com/RedHatQE/widgetastic.core 17 | .. _navmazing: https://github.com/RedhatQE/navmazing/ 18 | .. _documentation: https://airgun.readthedocs.io/ 19 | -------------------------------------------------------------------------------- /airgun/entities/about.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.navigation import NavigateStep, navigator 3 | from airgun.utils import retry_navigation 4 | from airgun.views.about import AboutView 5 | 6 | 7 | class AboutEntity(BaseEntity): 8 | endpoint_path = '/about' 9 | 10 | 11 | @navigator.register(AboutEntity, 'All') 12 | class ShowAboutPage(NavigateStep): 13 | """Navigate to About page.""" 14 | 15 | VIEW = AboutView 16 | 17 | @retry_navigation 18 | def step(self, *args, **kwargs): 19 | self.view.menu.select('Administer', 'About') 20 | -------------------------------------------------------------------------------- /airgun/entities/fact_value.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.navigation import NavigateStep, navigator 3 | from airgun.utils import retry_navigation 4 | from airgun.views.fact import HostFactView 5 | 6 | 7 | class FactValueEntity(BaseEntity): 8 | endpoint_path = '/fact_values' 9 | 10 | 11 | @navigator.register(FactValueEntity, 'All') 12 | class ShowFactValuePage(NavigateStep): 13 | """Navigate to Fact Values page.""" 14 | 15 | VIEW = HostFactView 16 | 17 | @retry_navigation 18 | def step(self, *args, **kwargs): 19 | self.view.menu.select('Monitor', 'Facts') 20 | -------------------------------------------------------------------------------- /airgun/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions raised by airgun""" 2 | 3 | from selenium.common.exceptions import InvalidElementStateException 4 | from widgetastic.exceptions import * # noqa: F403 5 | 6 | 7 | class ReadOnlyWidgetError(Exception): 8 | """Raised mainly when trying to fill a read only widget""" 9 | 10 | 11 | class DisabledWidgetError(Exception): 12 | """Raised when a widget is disabled, and not usable for some contexts scenarios""" 13 | 14 | 15 | class DestinationNotReachedError(Exception): 16 | """Raised when navigation destination view was not reached (not dispayed).""" 17 | 18 | 19 | __all__ = ['InvalidElementStateException'] 20 | -------------------------------------------------------------------------------- /airgun/entities/global_parameter.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.navigation import NavigateStep, navigator 3 | from airgun.utils import retry_navigation 4 | from airgun.views.global_parameter import GlobalParameterView 5 | 6 | 7 | class GlobalParameterEntity(BaseEntity): 8 | endpoint_path = '/common_parameters' 9 | 10 | 11 | @navigator.register(GlobalParameterEntity, 'All') 12 | class ShowGlobalParameters(NavigateStep): 13 | """Navigate to Global Parameters page.""" 14 | 15 | VIEW = GlobalParameterView 16 | 17 | @retry_navigation 18 | def step(self, *args, **kwargs): 19 | self.view.menu.select('Configure', 'Global Parameters') 20 | -------------------------------------------------------------------------------- /catalog-info.yaml: -------------------------------------------------------------------------------- 1 | # Catalog entry for Backstage [backstage.io] 2 | apiVersion: backstage.io/v1alpha1 3 | kind: Component 4 | metadata: 5 | name: airgun 6 | title: Airgun 7 | description: AirGun is a Python library that is built over Widgetastic and navmazing to make Satellite 6 UI testing easier. 8 | links: 9 | - title: Documentation 10 | url: https://airgun.readthedocs.io/en/latest/ 11 | tags: 12 | - python 13 | - rh-satellite 14 | - interaction-library 15 | namespace: quality-community 16 | annotations: 17 | github.com/project-slug: SatelliteQE/airgun 18 | spec: 19 | type: library 20 | owner: group:redhat/satellite-qe 21 | lifecycle: preproduction 22 | -------------------------------------------------------------------------------- /.github/workflows/auto_assignment.yaml: -------------------------------------------------------------------------------- 1 | name: 'Auto Assign' 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - ready_for_review 8 | - reopened 9 | - synchronize 10 | 11 | 12 | jobs: 13 | add-assignees: # This needed to create the gh issue in case of failed auto-cherry-pick 14 | name: Add author to assignee 15 | if: "!contains(github.event.pull_request.labels.*.name, 'Auto_Cherry_Picked')" 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: kentaro-m/auto-assign-action@v2.0.0 19 | with: 20 | repo-token: "${{ secrets.CHERRYPICK_PAT || github.token }}" 21 | configuration-path: ".github/auto_assign.yml" 22 | -------------------------------------------------------------------------------- /airgun/entities/rhai/overview.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.entities.rhai.base import InsightsNavigateStep 3 | from airgun.navigation import navigator 4 | from airgun.views.rhai import OverviewDetailsView 5 | 6 | 7 | class OverviewEntity(BaseEntity): 8 | endpoint_path = '/redhat_access/insights' 9 | 10 | def read(self, widget_names=None): 11 | view = self.navigate_to(self, 'Details') 12 | return view.read(widget_names=widget_names) 13 | 14 | 15 | @navigator.register(OverviewEntity, 'Details') 16 | class OverviewDetails(InsightsNavigateStep): 17 | VIEW = OverviewDetailsView 18 | 19 | def step(self, *args, **kwargs): 20 | self.view.menu.select('Insights', 'Overview') 21 | -------------------------------------------------------------------------------- /airgun/entities/upgrade.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.navigation import NavigateStep, navigator 3 | from airgun.utils import retry_navigation 4 | from airgun.views.upgrade import UpgradeView 5 | 6 | 7 | class UpgradeEntity(BaseEntity): 8 | endpoint_path = '/upgrade' 9 | 10 | def documentation_links(self): 11 | view = self.navigate_to(self, 'Home') 12 | return view.documentation_links() 13 | 14 | 15 | @navigator.register(UpgradeEntity, 'Home') 16 | class Upgrade(NavigateStep): 17 | """Navigate to Satellite Upgrade screen.""" 18 | 19 | VIEW = UpgradeView 20 | 21 | @retry_navigation 22 | def step(self, *args, **kwargs): 23 | self.view.menu.select('Administer', 'Satellite Upgrade') 24 | -------------------------------------------------------------------------------- /.github/workflows/trigger_robottelo_workflow.yml: -------------------------------------------------------------------------------- 1 | name: Send trigger for updating robottelo image on quay. 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - 6.*.z 7 | 8 | jobs: 9 | trigger-robottelo-workflow: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Trigger workflow in robottelo repo 13 | run: | 14 | curl -L \ 15 | -X POST \ 16 | -H "Accept: application/vnd.github+json" \ 17 | -H "Authorization: Bearer ${{ secrets.CHERRYPICK_PAT }}" \ 18 | -H "X-GitHub-Api-Version: 2022-11-28" \ 19 | https://api.github.com/repos/SatelliteQE/robottelo/actions/workflows/update_robottelo_image.yml/dispatches \ 20 | -d '{"ref":"'"${GITHUB_REF##*/}"'"}' 21 | -------------------------------------------------------------------------------- /settings.ini.example: -------------------------------------------------------------------------------- 1 | [airgun] 2 | verbosity=INFO 3 | tmp_dir=/var/tmp 4 | 5 | [satellite] 6 | hostname=example.com 7 | username=admin 8 | password=changeme 9 | 10 | [selenium] 11 | browser=selenium 12 | webdriver=chrome 13 | webdriver_binary=/home/user/path/to/chromedriver 14 | screenshots_path=/home/user/path/to/screenshots 15 | # browseroptions=headless 16 | 17 | [webkaifuku] 18 | config={'webdriver': 'chrome', 'webdriver_options': {'command_executor': 'http://localhost/wd/hub', 'desired_capabilities': {'browserName': 'chrome', 'chromeOptions': {'args': ['disable-web-security', 'ignore-certificate-errors'], 'prefs': {'download.prompt_for_download': False}}, 'platform': 'any', 'maxduration': 5400, 'idletimeout': 1000, 'start-maximised': True, 'screenresolution': '1600x1200'}}} 19 | -------------------------------------------------------------------------------- /airgun/entities/rhai/rule.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.entities.rhai.base import InsightsNavigateStep 3 | from airgun.navigation import navigator 4 | from airgun.views.rhai import AllRulesView 5 | 6 | 7 | class RuleEntity(BaseEntity): 8 | endpoint_path = '/redhat_access/insights/rules' 9 | 10 | def search(self, rule_name): 11 | """Perform the search of a rule.""" 12 | view = self.navigate_to(self, 'All') 13 | view.search.fill(rule_name) 14 | 15 | 16 | @navigator.register(RuleEntity, 'All') 17 | class RulesDetails(InsightsNavigateStep): 18 | """Navigate to Red Hat Access Insights Rules screen.""" 19 | 20 | VIEW = AllRulesView 21 | 22 | def step(self, *args, **kwargs): 23 | self.view.menu.select('Insights', 'Rules') 24 | -------------------------------------------------------------------------------- /airgun/entities/audit.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.navigation import NavigateStep, navigator 3 | from airgun.utils import retry_navigation 4 | from airgun.views.audit import AuditsView 5 | 6 | 7 | class AuditEntity(BaseEntity): 8 | endpoint_path = '/audits' 9 | 10 | def search(self, value): 11 | """Search for audit entry in logs and return first one from the list""" 12 | view = self.navigate_to(self, 'All') 13 | return view.search(value) 14 | 15 | 16 | @navigator.register(AuditEntity, 'All') 17 | class ShowAllAuditEntries(NavigateStep): 18 | """Navigate to Audit screen that contains all log entries""" 19 | 20 | VIEW = AuditsView 21 | 22 | @retry_navigation 23 | def step(self, *args, **kwargs): 24 | self.view.menu.select('Monitor', 'Audits') 25 | -------------------------------------------------------------------------------- /airgun/views/fact.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Table, Text 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import BaseLoggedInView, SearchableViewMixinPF4 5 | 6 | 7 | class HostFactView(BaseLoggedInView, SearchableViewMixinPF4): 8 | breadcrumb = BreadCrumb() 9 | 10 | table = Table( 11 | './/table', 12 | column_widgets={ 13 | 'Name': Text('./a'), 14 | }, 15 | ) 16 | expand_fact_value = Text( 17 | "//div/a[contains(@class, 'pf-v5-c-button') or contains(span/@class, 'pf-v5-c-icon')]" 18 | ) 19 | 20 | @property 21 | def is_displayed(self): 22 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 23 | return breadcrumb_loaded and self.breadcrumb.read().startswith('Facts Values') 24 | -------------------------------------------------------------------------------- /airgun/entities/rhai/action.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.entities.rhai.base import InsightsNavigateStep 3 | from airgun.navigation import navigator 4 | from airgun.views.rhai import ActionsDetailsView 5 | 6 | 7 | class ActionEntity(BaseEntity): 8 | endpoint_path = '/redhat_access/insights/actions' 9 | 10 | def read(self, widget_names=None): 11 | """Read the content of the view.""" 12 | view = self.navigate_to(self, 'Details') 13 | return view.read(widget_names=widget_names) 14 | 15 | 16 | @navigator.register(ActionEntity, 'Details') 17 | class ActionDetails(InsightsNavigateStep): 18 | """Navigate to Red Hat Access Insights Actions screen.""" 19 | 20 | VIEW = ActionsDetailsView 21 | 22 | def step(self, *args, **kwargs): 23 | self.view.menu.select('Insights', 'Actions') 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # This is the configuration file for Dependabot. You can find configuration information below. 2 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | # Note: Dependabot has a configurable max open PR limit of 5 4 | 5 | version: 2 6 | updates: 7 | # Maintain dependencies for Airgun itself 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | labels: 13 | - '6.18.z' 14 | - '6.17.z' 15 | - '6.16.z' 16 | - "CherryPick" 17 | - "dependencies" 18 | - "6.15.z" 19 | ignore: 20 | - dependency-name: "selenium" 21 | 22 | # Maintain dependencies for our GitHub Actions 23 | - package-ecosystem: "github-actions" 24 | directory: "/" 25 | schedule: 26 | interval: "daily" 27 | labels: 28 | - '6.18.z' 29 | - '6.17.z' 30 | - '6.16.z' 31 | - "CherryPick" 32 | - "dependencies" 33 | - "6.15.z" 34 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Variables ------------------------------------------------------------------- 2 | 3 | # Commands -------------------------------------------------------------------- 4 | 5 | help: 6 | @echo "Please use \`make \` where is one of" 7 | @echo " pyc-clean to delete all temporary artifacts" 8 | @echo " docs-html to generate HTML documentation" 9 | @echo " docs-clean to remove documentation" 10 | @echo " install to install in editable mode" 11 | 12 | pyc-clean: 13 | $(info Removing unused Python compiled files, caches and ~ backups...) 14 | find . -name '*.pyc' -exec rm -f {} + 15 | find . -name '*.pyo' -exec rm -f {} + 16 | find . -name '*~' -exec rm -f {} + 17 | find . -name '__pycache__' -exec rm -fr {} + 18 | 19 | docs-html: 20 | @cd docs; $(MAKE) html 21 | 22 | docs-clean: 23 | @cd docs; $(MAKE) clean 24 | 25 | docs-spelling: 26 | @cd docs; $(MAKE) spelling 27 | 28 | install: 29 | pip install -e . 30 | -------------------------------------------------------------------------------- /airgun/views/settings.py: -------------------------------------------------------------------------------- 1 | from wait_for import wait_for 2 | from widgetastic.widget import Table, Text 3 | from widgetastic_patternfly import Button 4 | 5 | from airgun.views.common import BaseLoggedInView, SatTab, SearchableViewMixin 6 | from airgun.widgets import FieldWithEditButton 7 | 8 | 9 | class SettingsView(BaseLoggedInView, SearchableViewMixin): 10 | title = Text("//h1[normalize-space(.)='Settings']") 11 | table = Table( 12 | './/table', 13 | column_widgets={'Value': FieldWithEditButton()}, 14 | ) 15 | 16 | @SatTab.nested 17 | class Email(SatTab): 18 | test_email_button = Button(id='test_mail_button') 19 | 20 | @property 21 | def is_displayed(self): 22 | return self.browser.wait_for_element(self.title, exception=False) is not None 23 | 24 | def wait_for_update(self): 25 | """Wait for value to update""" 26 | wait_for( 27 | lambda: self.table.row()['Value'].widget.is_displayed, 28 | timeout=30, 29 | delay=1, 30 | logger=self.logger, 31 | ) 32 | -------------------------------------------------------------------------------- /airgun/entities/rhai/base.py: -------------------------------------------------------------------------------- 1 | from airgun.exceptions import DestinationNotReachedError 2 | from airgun.navigation import NavigateStep 3 | from airgun.views.rhai import InsightsOrganizationErrorView 4 | 5 | 6 | class InsightsOrganizationPageError(Exception): 7 | """Raised when navigating to insight plugin pages and the organization is not selected 8 | or the current selected organization has no manifest. 9 | """ 10 | 11 | 12 | class InsightsNavigateStep(NavigateStep): 13 | def post_navigate(self, _tries, *args, **kwargs): 14 | """Raise Error if Destination view is not displayed or Organization Error page 15 | is displayed. 16 | """ 17 | if not self.view.is_displayed: 18 | org_err_page_view = InsightsOrganizationErrorView(self.view.browser) 19 | if org_err_page_view.is_displayed: 20 | raise InsightsOrganizationPageError(org_err_page_view.read()) 21 | raise DestinationNotReachedError( 22 | f'Navigation destination view "{self.view.__class__.__name__}" not reached' 23 | ) 24 | -------------------------------------------------------------------------------- /airgun/entities/login.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.navigation import NavigateStep, navigator 3 | from airgun.views.common import BaseLoggedInView 4 | from airgun.views.login import LoginView 5 | 6 | 7 | class LoginEntity(BaseEntity): 8 | def read_sat_version(self): 9 | view = self.navigate_to(self, 'NavigateToLogin') 10 | return view.read() 11 | 12 | def login(self, values): 13 | view = self.navigate_to(self, 'NavigateToLogin') 14 | view.fill(values) 15 | view.submit.click() 16 | 17 | def logout(self): 18 | view = BaseLoggedInView(self.browser) 19 | view.flash.assert_no_error() 20 | view.flash.dismiss() 21 | view.select_logout() 22 | view = LoginView(self.browser) 23 | return view.read() 24 | 25 | 26 | @navigator.register(LoginEntity) 27 | class NavigateToLogin(NavigateStep): 28 | VIEW = LoginView 29 | 30 | def step(self, *args, **kwargs): 31 | # logout() if logged_in? 32 | pass 33 | 34 | def am_i_here(self, *args, **kwargs): 35 | return self.view.is_displayed 36 | -------------------------------------------------------------------------------- /airgun/views/config_report.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Text 2 | from widgetastic_patternfly import BreadCrumb, Button 3 | 4 | from airgun.views.common import BaseLoggedInView, SatTable, SearchableViewMixin 5 | 6 | 7 | class ConfigReportsView(BaseLoggedInView, SearchableViewMixin): 8 | title = Text("//h1[normalize-space(.)='Reports']") 9 | export = Button('Export') 10 | table = SatTable( 11 | './/table', 12 | column_widgets={ 13 | 'Last report': Text('./a'), 14 | 'Actions': Text('.//a[@data-method="delete"]'), 15 | }, 16 | ) 17 | 18 | @property 19 | def is_displayed(self): 20 | return self.browser.wait_for_element(self.title, exception=False) is not None 21 | 22 | 23 | class ConfigReportDetailsView(BaseLoggedInView): 24 | breadcrumb = BreadCrumb() 25 | 26 | @property 27 | def is_displayed(self): 28 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 29 | return breadcrumb_loaded and self.breadcrumb.locations[0] == 'Config Reports' 30 | 31 | delete = Button('Delete') 32 | host_details = Button('Host details') 33 | -------------------------------------------------------------------------------- /airgun/views/bootc.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Table, Text, View 2 | from widgetastic_patternfly4.ouia import ExpandableTable 3 | 4 | from airgun.views.common import ( 5 | BaseLoggedInView, 6 | SearchableViewMixinPF4, 7 | ) 8 | 9 | 10 | class BootedContainerImagesView(BaseLoggedInView, SearchableViewMixinPF4): 11 | title = Text('.//h1[@data-ouia-component-id="header-text"]') 12 | 13 | # This represents the contents of the expanded table rows 14 | class NestedBootCTable(View): 15 | table = Table( 16 | locator='.//div[@class="pf-c-table__expandable-row-content"]/table', 17 | column_widgets={'Image Digest': Text('./a'), 'Hosts': Text('./a')}, 18 | ) 19 | 20 | # Passing in the nested table as content_view, refer to ExpandableTable docs for info 21 | table = ExpandableTable( 22 | component_id='booted-containers-table', 23 | column_widgets={ 24 | 'Image Name': Text('./a'), 25 | 'Image Digests': Text('./a'), 26 | 'Hosts': Text('./a'), 27 | }, 28 | content_view=NestedBootCTable(), 29 | ) 30 | 31 | @property 32 | def is_displayed(self): 33 | return self.table.is_displayed 34 | -------------------------------------------------------------------------------- /airgun/entities/rhai/manage.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.entities.rhai.base import InsightsNavigateStep 3 | from airgun.navigation import navigator 4 | from airgun.views.rhai import ManageDetailsView 5 | 6 | 7 | class ManageEntity(BaseEntity): 8 | endpoint_path = '/redhat_access/insights/manage' 9 | 10 | def _toggle_service(self, state): 11 | view = self.navigate_to(self, 'Details') 12 | if view.enable_service.fill(state): 13 | view.save.click() 14 | 15 | def enable_service(self): 16 | """Enable the service.""" 17 | self._toggle_service(True) 18 | 19 | def disable_service(self): 20 | """Disable the service.""" 21 | self._toggle_service(False) 22 | 23 | def read(self, widget_names=None): 24 | """Read the content of the view.""" 25 | view = self.navigate_to(self, 'Details') 26 | view.check_connection.click() 27 | return view.read(widget_names=widget_names) 28 | 29 | 30 | @navigator.register(ManageEntity, 'Details') 31 | class ManageDetails(InsightsNavigateStep): 32 | """Navigate to Red Hat Access Insights Manage screen.""" 33 | 34 | VIEW = ManageDetailsView 35 | 36 | def step(self, *args, **kwargs): 37 | self.view.menu.select('Insights', 'Manage') 38 | -------------------------------------------------------------------------------- /airgun/views/eol_banner.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import ClickableMixin, Text, View 2 | 3 | 4 | class EOLBannerView(View, ClickableMixin): 5 | name = Text('//div[@id="satellite-eol-banner"]') 6 | dismiss_button = Text('//*[@id="satellite-oel-banner-dismiss-button"]') 7 | LIFECYCLE_LINK = '//a[text()[normalize-space(.) = "Red Hat Satellite Product Life Cycle"]]' 8 | HELPER_LINK = '//a[text()[normalize-space(.) = "Red Hat Satellite Upgrade Helper."]]' 9 | 10 | @property 11 | def warning(self): 12 | """Return whether the banner is displayed in warning style""" 13 | return 'warning' in ' '.join(self.browser.classes(self.name)) 14 | 15 | @property 16 | def danger(self): 17 | """Return whether the banner is displayed in danger style""" 18 | return 'danger' in ' '.join(self.browser.classes(self.name)) 19 | 20 | @property 21 | def lifecycle_link(self): 22 | """Return the result link element of this row""" 23 | return self.browser.element(self.LIFECYCLE_LINK) 24 | 25 | @property 26 | def helper_link(self): 27 | """Return the result link element of this row""" 28 | return self.browser.element(self.HELPER_LINK) 29 | 30 | @property 31 | def is_displayed(self): 32 | return self.name.is_displayed 33 | -------------------------------------------------------------------------------- /airgun/entities/bootc.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.navigation import NavigateStep, navigator 3 | from airgun.utils import retry_navigation 4 | from airgun.views.bootc import BootedContainerImagesView 5 | 6 | 7 | class BootcEntity(BaseEntity): 8 | endpoint_path = '/booted_container_images' 9 | 10 | def read(self, booted_image_name): 11 | """ 12 | Read the expanded row of a specific booted_image, returns a tuple 13 | with the unexpanded content, and the expanded content 14 | """ 15 | view = self.navigate_to(self, 'All') 16 | self.browser.plugin.ensure_page_safe(timeout='5s') 17 | view.search(f'bootc_booted_image = {booted_image_name}') 18 | view.table.row(image_name=booted_image_name).expand() 19 | row = view.table.row(image_name=booted_image_name).read() 20 | row_content = view.table.row(image_name=booted_image_name).content.read() 21 | return (row, row_content['table'][0]) 22 | 23 | 24 | @navigator.register(BootcEntity, 'All') 25 | class BootedImagesScreen(NavigateStep): 26 | """Navigate to Booted Container Images screen.""" 27 | 28 | VIEW = BootedContainerImagesView 29 | 30 | @retry_navigation 31 | def step(self, *args, **kwargs): 32 | self.view.menu.select('Content', 'Booted Container Images') 33 | -------------------------------------------------------------------------------- /airgun/views/rhsso_login.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import ClickableMixin, Text, TextInput, View 2 | 3 | 4 | class RhssoLoginView(View, ClickableMixin): 5 | username = TextInput(id='username') 6 | password = TextInput(id='password') 7 | submit = Text('//input[@name="login"]') 8 | error_message = Text('//span[@id="input-error"]') 9 | 10 | @property 11 | def is_displayed(self): 12 | return self.browser.wait_for_element(self.username, exception=False) is not None 13 | 14 | 15 | class RhssoExternalLogoutView(View, ClickableMixin): 16 | login_again = Text('//a[@href="/users/extlogin"]') 17 | logo = Text('//img[@alt="logo"]') 18 | 19 | @property 20 | def is_displayed(self): 21 | return self.browser.wait_for_element(self.login_again, exception=False) is not None 22 | 23 | 24 | class RhssoTwoFactorSuccessView(View, ClickableMixin): 25 | code = TextInput(id='code') 26 | 27 | @property 28 | def is_displayed(self): 29 | return self.browser.wait_for_element(self.code, exception=False) is not None 30 | 31 | 32 | class RhssoTotpView(View, ClickableMixin): 33 | totp = TextInput(id='otp') 34 | submit = Text('//input[@name="login"]') 35 | 36 | @property 37 | def is_displayed(self): 38 | return self.browser.wait_for_element(self.totp, exception=False) is not None 39 | -------------------------------------------------------------------------------- /.github/workflows/dispatch_release.yml: -------------------------------------------------------------------------------- 1 | ### The auto release workflow triggered through dispatch request from CI 2 | name: auto-release 3 | 4 | # Run on workflow dispatch from CI 5 | on: 6 | workflow_dispatch: 7 | inputs: 8 | tag_name: 9 | type: string 10 | description: Name of the tag 11 | 12 | jobs: 13 | auto-tag-and-release: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v6 18 | 19 | - name: Git User setup 20 | run: "git config --local user.email Satellite-QE.satqe.com && git config --local user.name Satellite-QE" 21 | 22 | - name: Tag latest commit 23 | run: "git tag -a ${{ github.event.inputs.tag_name }} -m 'Tagged By SatelliteQE Automation User'" 24 | 25 | - name: Push the tag to the upstream 26 | run: "git push ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git --tags" 27 | 28 | - name: create a new release from the tag 29 | env: 30 | credentials: ${{ secrets.GH_TOKEN }} 31 | run: "curl -L -X POST -H \"Authorization: Bearer ${{ secrets.SATQE_GH_TOKEN }}\" ${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases -d '{\"tag_name\": \"${{ github.event.inputs.tag_name }}\", \"target_commitish\":\"master\", \"name\":\"${{ github.event.inputs.tag_name }}\", \"draft\":false, \"prerelease\":true, \"generate_release_notes\": true}'" 32 | -------------------------------------------------------------------------------- /airgun/helpers/base.py: -------------------------------------------------------------------------------- 1 | class BaseEntityHelper: 2 | def __init__(self, entity): 3 | self._entity = entity 4 | 5 | @property 6 | def entity(self): 7 | """Returns the entity associated with this helper.""" 8 | return self._entity 9 | 10 | def read_filled_view( 11 | self, navigation_name, navigation_kwargs=None, values=None, read_widget_names=None 12 | ): 13 | """Navigate to a form using 'navigation_name' and with parameters from 'navigation_kwargs', 14 | fill the form with values and then read values for widgets from 'read_widget_names' list if 15 | supplied otherwise read all widgets values. 16 | 17 | Usage:: 18 | 19 | # In host entity: open create view, click host.reset_puppet_environment button and read 20 | # host.puppet_environment 21 | session.host.helper.read_filled_view( 22 | 'New', 23 | values={'host.reset_puppet_environment': True}, 24 | read_widget_names=['host.puppet_environment'], 25 | ) 26 | 27 | """ 28 | navigation_kwargs = navigation_kwargs or {} 29 | values = values or {} 30 | view = self.entity.navigate_to(self.entity, name=navigation_name, **navigation_kwargs) 31 | view.fill(values) 32 | return view.read(widget_names=read_widget_names) 33 | -------------------------------------------------------------------------------- /airgun/entities/eol_banner.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.navigation import NavigateStep, navigator 3 | from airgun.views.eol_banner import EOLBannerView 4 | 5 | 6 | class EOLBannerEntity(BaseEntity): 7 | def read(self): 8 | view = self.navigate_to(self, 'NavigateToEOLBanner') 9 | return view.read() 10 | 11 | def dismiss(self): 12 | view = self.navigate_to(self, 'NavigateToEOLBanner') 13 | view.dismiss_button.click() 14 | 15 | def is_warning(self): 16 | view = self.navigate_to(self, 'NavigateToEOLBanner') 17 | return view.warning 18 | 19 | def is_danger(self): 20 | view = self.navigate_to(self, 'NavigateToEOLBanner') 21 | return view.danger 22 | 23 | def lifecycle_link(self): 24 | view = self.navigate_to(self, 'NavigateToEOLBanner') 25 | return view.lifecycle_link.get_attribute('href') 26 | 27 | def helper_link(self): 28 | view = self.navigate_to(self, 'NavigateToEOLBanner') 29 | return view.helper_link.get_attribute('href') 30 | 31 | 32 | @navigator.register(EOLBannerEntity) 33 | class NavigateToEOLBanner(NavigateStep): 34 | VIEW = EOLBannerView 35 | 36 | def step(self, *args, **kwargs): 37 | self.view.wait_displayed() 38 | 39 | def am_i_here(self, *args, **kwargs): 40 | return self.view.is_displayed 41 | -------------------------------------------------------------------------------- /airgun/views/bookmark.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Checkbox, Text, TextInput 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import BaseLoggedInView, SearchableViewMixinPF4 5 | from airgun.widgets import SatTable 6 | 7 | 8 | class BookmarksView(BaseLoggedInView, SearchableViewMixinPF4): 9 | title = Text("//h1[normalize-space(.)='Bookmarks']") 10 | table = SatTable( 11 | './/table', 12 | column_widgets={ 13 | 'Name': Text('./a'), 14 | 'Actions': Text('./span/a'), 15 | }, 16 | ) 17 | 18 | @property 19 | def is_displayed(self): 20 | return self.browser.wait_for_element(self.title, exception=False) is not None 21 | 22 | 23 | class BookmarkEditView(BaseLoggedInView): 24 | breadcrumb = BreadCrumb() 25 | name = TextInput(id='bookmark_name') 26 | query = TextInput(id='bookmark_query') 27 | public = Checkbox(id='bookmark_public') 28 | submit = Text(".//input[@type='submit']") 29 | cancel = Text(".//a[normalize-space(.)='Cancel']") 30 | 31 | @property 32 | def is_displayed(self): 33 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 34 | return ( 35 | breadcrumb_loaded 36 | and self.breadcrumb.locations[0] == 'Bookmarks' 37 | and self.breadcrumb.read().startswith('Edit') 38 | ) 39 | -------------------------------------------------------------------------------- /airgun/fixtures.py: -------------------------------------------------------------------------------- 1 | """Handy fixtures which you may want to use in your tests. 2 | 3 | Just add the following line into your `conftest.py`:: 4 | 5 | pytest_plugins = ["airgun.fixtures"] 6 | 7 | """ 8 | 9 | import pytest 10 | 11 | from airgun.session import Session 12 | 13 | 14 | @pytest.fixture() 15 | def session(request): 16 | """Session fixture which automatically initializes (but does not start!) 17 | airgun UI session and correctly passes current test name to it. 18 | 19 | 20 | Usage:: 21 | 22 | def test_foo(session): 23 | with session: 24 | # your ui test steps here 25 | session.architecture.create({'name': 'bar'}) 26 | 27 | """ 28 | test_name = f'{request.module.__name__}.{request.node.name}' 29 | return Session(test_name) 30 | 31 | 32 | @pytest.fixture() 33 | def autosession(request): 34 | """Session fixture which automatically initializes and starts airgun UI 35 | session and correctly passes current test name to it. Use it when you want 36 | to have a session started before test steps and closed after all of them, 37 | i.e. when you don't need manual control over when the session is started or 38 | closed. 39 | 40 | Usage:: 41 | 42 | def test_foo(autosession): 43 | # your ui test steps here 44 | autosession.architecture.create({'name': 'bar'}) 45 | 46 | """ 47 | test_name = f'{request.module.__name__}.{request.node.name}' 48 | with Session(test_name) as started_session: 49 | yield started_session 50 | -------------------------------------------------------------------------------- /airgun/views/file.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Text, View 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import ( 5 | BaseLoggedInView, 6 | ReadOnlyEntry, 7 | SatTab, 8 | SatTable, 9 | ) 10 | from airgun.widgets import Search 11 | 12 | 13 | class FilesView(BaseLoggedInView): 14 | """Main Files view""" 15 | 16 | title = Text("//h1[contains(., 'Files')]") 17 | table = SatTable('.//table', column_widgets={'Name': Text('./a'), 'Path': Text('./a')}) 18 | 19 | search_box = Search() 20 | 21 | def search(self, query): 22 | self.search_box.search(query) 23 | return self.table.read() 24 | 25 | @property 26 | def is_displayed(self): 27 | return self.browser.wait_for_element(self.title, exception=False) is not None 28 | 29 | 30 | class FileDetailsView(BaseLoggedInView): 31 | breadcrumb = BreadCrumb() 32 | 33 | @property 34 | def is_displayed(self): 35 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 36 | 37 | return breadcrumb_loaded and self.breadcrumb.locations[0] == 'Files' 38 | 39 | @View.nested 40 | class details(SatTab): 41 | path = ReadOnlyEntry(name='Checksum') 42 | checksum = ReadOnlyEntry(name='Path') 43 | 44 | @View.nested 45 | class content_views(SatTab): 46 | TAB_NAME = 'Content Views' 47 | cvtable = SatTable( 48 | './/table', 49 | column_widgets={ 50 | 'Name': Text('./a'), 51 | 'Environment': Text('./a'), 52 | 'Version': Text('./a'), 53 | }, 54 | ) 55 | -------------------------------------------------------------------------------- /airgun/views/modulestream.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Table, Text, View 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import ( 5 | BaseLoggedInView, 6 | SatTab, 7 | SatTable, 8 | SearchableViewMixinPF4, 9 | ) 10 | from airgun.widgets import SatTableWithUnevenStructure 11 | 12 | 13 | class ModuleStreamView(BaseLoggedInView, SearchableViewMixinPF4): 14 | """Main Module_Streams view""" 15 | 16 | title = Text("//h2[contains(., 'Module Streams')]") 17 | table = SatTable('.//table', column_widgets={'Name': Text('./a')}) 18 | 19 | @property 20 | def is_displayed(self): 21 | """The view is displayed when it's title exists""" 22 | return self.browser.wait_for_element(self.title, exception=False) is not None 23 | 24 | 25 | class ModuleStreamsDetailsView(BaseLoggedInView): 26 | breadcrumb = BreadCrumb() 27 | 28 | title = Text("//a(., 'Module Streams')]") 29 | details_tab = Text("//a[@id='module-stream-tabs-container-tab-1']") 30 | 31 | @property 32 | def is_displayed(self): 33 | """Assume the view is displayed when its breadcrumb is visible""" 34 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 35 | return breadcrumb_loaded and self.breadcrumb.locations[0] == 'Module Streams' 36 | 37 | @View.nested 38 | class details(SatTab): 39 | details_table = SatTableWithUnevenStructure(locator='.//table', column_locator='./*') 40 | 41 | @View.nested 42 | class repositories(SatTab): 43 | table = Table( 44 | locator='.//table', 45 | column_widgets={ 46 | 'Name': Text('./a'), 47 | }, 48 | ) 49 | -------------------------------------------------------------------------------- /airgun/views/audit.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Text, View 2 | 3 | from airgun.exceptions import ReadOnlyWidgetError 4 | from airgun.views.common import BaseLoggedInView, SearchableViewMixinPF4 5 | from airgun.widgets import SatTableWithoutHeaders 6 | 7 | 8 | class AuditEntry(View): 9 | ROOT = ".//div[@id='audit-list']/ul/li" 10 | user = Text(".//a[@class='user-info']") 11 | action_type = Text(".//div[contains(@class, 'pf-v5-c-data-list__cell')][2]") 12 | resource_type = Text(".//div[contains(@class, 'item-name')]") 13 | resource_name = Text(".//div[contains(@class, 'item-resource')]") 14 | created_at = Text(".//div[contains(@class, 'audits-list-actions')]/span") 15 | expander = Text(".//*[@aria-label='Details']") 16 | affected_organization = Text("(.//a[@data-ouia-component-id='taxonomy-inline-btn'])[1]") 17 | affected_location = Text("(.//a[@data-ouia-component-id='taxonomy-inline-btn'])[2]") 18 | action_summary = SatTableWithoutHeaders('.//table') 19 | comment = Text(".//p[@class='comment-desc']") 20 | 21 | @property 22 | def expanded(self): 23 | return self.browser.get_attribute('aria-expanded', self.expander) == 'true' 24 | 25 | def read(self): 26 | if not self.expanded: 27 | self.expander.click() 28 | return super().read() 29 | 30 | def fill(self, values): 31 | raise ReadOnlyWidgetError('View is read only, fill is prohibited') 32 | 33 | 34 | class AuditsView(BaseLoggedInView, SearchableViewMixinPF4): 35 | title = Text("//h1[normalize-space(.)='Audits']") 36 | table = AuditEntry() 37 | 38 | @property 39 | def is_displayed(self): 40 | return self.browser.wait_for_element(self.title, exception=False) is not None 41 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import find_packages, setup 3 | 4 | with open('README.rst') as f: 5 | README = f.read() 6 | 7 | setup( 8 | name='airgun', 9 | version='0.0.1', # Should be identical to the version in docs/conf.py! 10 | description=( 11 | 'A library which is build over Widgetastic and navmazing to make ' 12 | 'Satellite 6 UI testing easier.' 13 | ), 14 | long_description=README, 15 | author='RedHat QE Team', 16 | url='https://github.com/SatelliteQE/airgun', 17 | install_requires=[ 18 | 'cached_property', 19 | 'fauxfactory', 20 | 'navmazing', 21 | 'python-box', 22 | 'pytest', 23 | 'wait_for', 24 | 'webdriver-kaifuku', 25 | 'selenium==4.21.0', 26 | 'widgetastic.core>=1.1,<2.0', 27 | 'widgetastic.patternfly<2.0', 28 | 'widgetastic.patternfly4<2.0', 29 | 'widgetastic.patternfly5<26.0', 30 | ], 31 | packages=find_packages(exclude=['tests*']), 32 | package_data={'': ['LICENSE']}, 33 | include_package_data=True, 34 | license='GNU GPL v3.0', 35 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 36 | classifiers=[ 37 | 'Development Status :: 1 - Planning', 38 | 'Intended Audience :: Developers', 39 | 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 40 | 'Natural Language :: English', 41 | 'Operating System :: POSIX :: LinuxProgramming Language :: Python', 42 | 'Programming Language :: Python :: 3', 43 | 'Programming Language :: Python :: 3.12', 44 | 'Programming Language :: Python :: 3.13', 45 | 'Programming Language :: Python :: 3.14', 46 | ], 47 | ) 48 | -------------------------------------------------------------------------------- /airgun/entities/sync_templates.py: -------------------------------------------------------------------------------- 1 | from wait_for import wait_for 2 | 3 | from airgun.entities.base import BaseEntity 4 | from airgun.navigation import NavigateStep, navigator 5 | from airgun.utils import retry_navigation 6 | from airgun.views.sync_templates import SyncTemplatesView, TemplatesReportView 7 | 8 | 9 | class SyncTemplatesEntity(BaseEntity): 10 | endpoint_path = '/template_syncs' 11 | 12 | def sync(self, values): 13 | """Import Export Switch Action Entity""" 14 | view = self.navigate_to(self, 'Sync') 15 | view.fill(values) 16 | view.submit.click() 17 | self.browser.plugin.ensure_page_safe() 18 | if view.validations.messages: 19 | raise AssertionError( 20 | f'Validation Errors are present on Page. Messages are {view.validations.messages}' 21 | ) 22 | reports_view = TemplatesReportView(self.browser) 23 | wait_for( 24 | lambda: reports_view.is_displayed is True, 25 | timeout=60, 26 | delay=1, 27 | logger=reports_view.logger, 28 | ) 29 | return reports_view.title.read() 30 | 31 | 32 | @navigator.register(SyncTemplatesEntity, 'Main') 33 | class SyncMainPageNavigation(NavigateStep): 34 | """Navigate to Import/Export Templates page""" 35 | 36 | VIEW = SyncTemplatesView 37 | 38 | @retry_navigation 39 | def step(self, *args, **kwargs): 40 | self.view.menu.select('Hosts', 'Templates', 'Sync Templates') 41 | 42 | 43 | @navigator.register(SyncTemplatesEntity, 'Sync') 44 | class SyncTemplatesActionNavigation(NavigateStep): 45 | """Navigate to Import/Export Templates page""" 46 | 47 | VIEW = SyncTemplatesView 48 | 49 | def prerequisite(self, *args, **kwargs): 50 | return self.navigate_to(self.obj, 'Main') 51 | -------------------------------------------------------------------------------- /airgun/entities/rhsso_login.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.navigation import NavigateStep, navigator 3 | from airgun.views.common import BaseLoggedInView 4 | from airgun.views.rhsso_login import ( 5 | RhssoExternalLogoutView, 6 | RhssoLoginView, 7 | RhssoTotpView, 8 | RhssoTwoFactorSuccessView, 9 | ) 10 | 11 | 12 | class RHSSOLoginEntity(BaseEntity): 13 | def login(self, values, external_login=False, totp=None): 14 | if external_login: 15 | view = RhssoExternalLogoutView(self.browser) 16 | view.login_again.click() 17 | else: 18 | view = self.navigate_to(self, 'NavigateToLogin') 19 | view.fill(values) 20 | view.submit.click() 21 | if totp: 22 | view = RhssoTotpView(self.browser) 23 | view.fill(totp) 24 | view.submit.click() 25 | # Check if we're still on login page (login failed) 26 | login_view = RhssoLoginView(self.browser) 27 | if login_view.is_displayed: 28 | return login_view.read() 29 | 30 | def logout(self): 31 | view = BaseLoggedInView(self.browser) 32 | view.select_logout() 33 | view.flash.assert_no_error() 34 | view.flash.dismiss() 35 | view = RhssoExternalLogoutView(self.browser) 36 | return view.read() 37 | 38 | def get_two_factor_login_code(self, values, url): 39 | self.browser.selenium.get(url) 40 | self.login(values) 41 | view = RhssoTwoFactorSuccessView(self.browser) 42 | return view.read() 43 | 44 | 45 | @navigator.register(RHSSOLoginEntity) 46 | class NavigateToLogin(NavigateStep): 47 | VIEW = RhssoLoginView 48 | 49 | def am_i_here(self, *args, **kwargs): 50 | return self.view.is_displayed 51 | -------------------------------------------------------------------------------- /airgun/views/configgroup.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Table, Text, TextInput 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import BaseLoggedInView, SearchableViewMixin 5 | from airgun.widgets import PuppetClassesMultiSelect 6 | 7 | 8 | class ConfigGroupsView(BaseLoggedInView, SearchableViewMixin): 9 | title = Text("//h1[normalize-space(.)='Config Groups']") 10 | new = Text("//a[normalize-space(.)='Create Config Group']") 11 | table = Table( 12 | './/table', 13 | column_widgets={ 14 | 'Name': Text('./a'), 15 | 'Actions': Text('.//a[@data-method="delete"]'), 16 | }, 17 | ) 18 | 19 | @property 20 | def is_displayed(self): 21 | return self.browser.wait_for_element(self.title, exception=False) is not None 22 | 23 | 24 | class ConfigGroupCreateView(BaseLoggedInView): 25 | breadcrumb = BreadCrumb() 26 | name = TextInput(id='config_group_name') 27 | submit = Text('//input[@name="commit"]') 28 | classes = PuppetClassesMultiSelect(locator='.//form') 29 | 30 | @property 31 | def is_displayed(self): 32 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 33 | return ( 34 | breadcrumb_loaded 35 | and self.breadcrumb.locations[0] == 'Config Groups' 36 | and self.breadcrumb.locations[1] == 'Create Config Group' 37 | ) 38 | 39 | 40 | class ConfigGroupEditView(ConfigGroupCreateView): 41 | @property 42 | def is_displayed(self): 43 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 44 | return ( 45 | breadcrumb_loaded 46 | and self.breadcrumb.locations[0] == 'Config Groups' 47 | and self.breadcrumb.read().startswith('Edit ') 48 | ) 49 | -------------------------------------------------------------------------------- /airgun/entities/settings.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.navigation import NavigateStep, navigator 3 | from airgun.utils import retry_navigation 4 | from airgun.views.common import BaseLoggedInView 5 | from airgun.views.settings import SettingsView 6 | 7 | 8 | class SettingsEntity(BaseEntity): 9 | endpoint_path = '/settings' 10 | 11 | def search(self, value): 12 | """Search for necessary settings entry""" 13 | view = self.navigate_to(self, 'All') 14 | return view.search(value) 15 | 16 | def read(self, property_name): 17 | """Read settings values""" 18 | view = self.navigate_to(self, 'All') 19 | view.search(property_name) 20 | return view.read() 21 | 22 | def update(self, property_name, value): 23 | """Update setting property with provided value""" 24 | view = self.navigate_to(self, 'All') 25 | view.search(property_name) 26 | view.table.row()['Value'].widget.fill(value) 27 | view.validations.assert_no_errors() 28 | view.wait_for_update() 29 | 30 | def send_test_mail(self, property_name): 31 | """Send the mail to the recipient""" 32 | view = self.navigate_to(self, 'All') 33 | view.search(property_name) 34 | view.Email.test_email_button.click() 35 | return view.flash.read() 36 | 37 | def permission_denied(self): 38 | """Return permission denied error text""" 39 | view = BaseLoggedInView(self.browser) 40 | return view.permission_denied.text 41 | 42 | 43 | @navigator.register(SettingsEntity, 'All') 44 | class ShowAllSettings(NavigateStep): 45 | """Navigate to All Settings page""" 46 | 47 | VIEW = SettingsView 48 | 49 | @retry_navigation 50 | def step(self, *args, **kwargs): 51 | self.view.menu.select('Administer', 'Settings') 52 | -------------------------------------------------------------------------------- /airgun/entities/dashboard.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from airgun.entities.base import BaseEntity 4 | from airgun.navigation import NavigateStep, navigator 5 | from airgun.utils import retry_navigation 6 | from airgun.views.dashboard import DashboardView 7 | 8 | 9 | class DashboardEntity(BaseEntity): 10 | def search(self, value): 11 | """Initiate search procedure that applied on all dashboard widgets. 12 | Return widgets values as a result 13 | """ 14 | view = self.navigate_to(self, 'All') 15 | return view.search(value) 16 | 17 | def read(self, widget_name): 18 | """Read specific widget value""" 19 | view = self.navigate_to(self, 'All') 20 | if widget_name not in view.widget_names: 21 | raise ValueError('Provide correct widget name to be read') 22 | time.sleep(3) 23 | return getattr(view, widget_name).read() 24 | 25 | def read_all(self): 26 | """Read all dashboard widgets values""" 27 | view = self.navigate_to(self, 'All') 28 | return view.read() 29 | 30 | def action(self, values): 31 | """Perform action against specific widget. In most cases, re-direction 32 | to another entity is happened 33 | """ 34 | view = self.navigate_to(self, 'All') 35 | view.fill(values) 36 | 37 | 38 | @navigator.register(DashboardEntity, 'All') 39 | class OpenDashboard(NavigateStep): 40 | """Navigate to Dashboard page""" 41 | 42 | VIEW = DashboardView 43 | 44 | @retry_navigation 45 | def step(self, *args, **kwargs): 46 | self.view.menu.select('Monitor', 'Dashboard') 47 | 48 | def post_navigate(self, _tries=0, *args, **kwargs): 49 | """Disable auto-refresh feature for dashboard entity each time 50 | navigation to the page is finished 51 | """ 52 | self.view.refresh.fill(False) 53 | -------------------------------------------------------------------------------- /airgun/views/architecture.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Table, Text, TextInput 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import BaseLoggedInView, SearchableViewMixinPF4 5 | from airgun.widgets import MultiSelect 6 | 7 | 8 | class ArchitecturesView(BaseLoggedInView, SearchableViewMixinPF4): 9 | title = Text("//h1[normalize-space(.)='Architectures']") 10 | new = Text("//a[contains(@href, '/architectures/new')]") 11 | table = Table( 12 | './/table', 13 | column_widgets={ 14 | 'Name': Text('./a'), 15 | 'Actions': Text('.//a[@data-method="delete"]'), 16 | }, 17 | ) 18 | 19 | @property 20 | def is_displayed(self): 21 | return self.browser.wait_for_element(self.title, exception=False) is not None 22 | 23 | 24 | class ArchitectureDetailsView(BaseLoggedInView): 25 | breadcrumb = BreadCrumb() 26 | name = TextInput(locator="//input[@id='architecture_name']") 27 | submit = Text('//input[@name="commit"]') 28 | operatingsystems = MultiSelect(id='ms-architecture_operatingsystem_ids') 29 | 30 | @property 31 | def is_displayed(self): 32 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 33 | return ( 34 | breadcrumb_loaded 35 | and self.breadcrumb.locations[0] == 'Architectures' 36 | and self.breadcrumb.read().startswith('Edit ') 37 | ) 38 | 39 | 40 | class ArchitectureCreateView(ArchitectureDetailsView): 41 | @property 42 | def is_displayed(self): 43 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 44 | return ( 45 | breadcrumb_loaded 46 | and self.breadcrumb.locations[0] == 'Architectures' 47 | and self.breadcrumb.read() == 'Create Architecture' 48 | ) 49 | -------------------------------------------------------------------------------- /airgun/entities/file.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.navigation import NavigateStep, navigator 3 | from airgun.utils import retry_navigation 4 | from airgun.views.file import FileDetailsView, FilesView 5 | 6 | 7 | class FilesEntity(BaseEntity): 8 | endpoint_path = '/files' 9 | 10 | def search(self, query): 11 | view = self.navigate_to(self, 'All') 12 | return view.search(query) 13 | 14 | def read(self, entity_name, widget_names=None): 15 | view = self.navigate_to(self, 'Details', entity_name=entity_name) 16 | return view.read(widget_names=widget_names) 17 | 18 | def read_cv_table(self, entity_name): 19 | view = self.navigate_to(self, 'Details', entity_name=entity_name) 20 | return view.content_views.cvtable.read() 21 | 22 | 23 | @navigator.register(FilesEntity, 'All') 24 | class ShowAllFiles(NavigateStep): 25 | """navigate to Files Page""" 26 | 27 | VIEW = FilesView 28 | 29 | @retry_navigation 30 | def step(self, *args, **kwargs): 31 | self.view.menu.select('Content', 'Content Types', 'Files') 32 | 33 | 34 | @navigator.register(FilesEntity, 'Details') 35 | class ShowPackageDetails(NavigateStep): 36 | """Navigate to File Details page by clicking on file name""" 37 | 38 | VIEW = FileDetailsView 39 | 40 | def prerequisite(self, *args, **kwargs): 41 | return self.navigate_to(self.obj, 'All') 42 | 43 | def step(self, *args, **kwargs): 44 | entity_name = kwargs.get('entity_name') 45 | self.parent.search(f'name = {entity_name}') 46 | self.parent.table.row(name=entity_name)['Name'].widget.click() 47 | 48 | def am_i_here(self, *args, **kwargs): 49 | entity_name = kwargs.get('entity_name') 50 | self.view.file_name = entity_name 51 | return self.view.is_displayed and self.view.breadcrumb.locations[1] == entity_name 52 | -------------------------------------------------------------------------------- /airgun/views/role.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Text, TextInput 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import BaseLoggedInView, SearchableViewMixinPF4 5 | from airgun.widgets import ActionsDropdown, MultiSelect, SatTable 6 | 7 | 8 | class RolesView(BaseLoggedInView, SearchableViewMixinPF4): 9 | title = Text("//h1[normalize-space(.)='Roles']") 10 | new = Text("//a[contains(@href, '/roles/new')]") 11 | table = SatTable( 12 | './/table', 13 | column_widgets={ 14 | 'Name': Text('./span/a'), 15 | 'Actions': ActionsDropdown("./div[contains(@class, 'btn-group')]"), 16 | }, 17 | ) 18 | 19 | @property 20 | def is_displayed(self): 21 | return self.browser.wait_for_element(self.title, exception=False) is not None 22 | 23 | 24 | class RoleEditView(BaseLoggedInView): 25 | breadcrumb = BreadCrumb() 26 | name = TextInput(id='role_name') 27 | description = TextInput(id='role_description') 28 | locations = MultiSelect(id='ms-role_location_ids') 29 | organizations = MultiSelect(id='ms-role_organization_ids') 30 | submit = Text('//input[@name="commit"]') 31 | 32 | @property 33 | def is_displayed(self): 34 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 35 | return ( 36 | breadcrumb_loaded 37 | and self.breadcrumb.locations[0] == 'Roles' 38 | and self.breadcrumb.read().startswith('Edit ') 39 | ) 40 | 41 | 42 | class RoleCreateView(RoleEditView): 43 | @property 44 | def is_displayed(self): 45 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 46 | return ( 47 | breadcrumb_loaded 48 | and self.breadcrumb.locations[0] == 'Roles' 49 | and self.breadcrumb.read() == 'Create Role' 50 | ) 51 | 52 | 53 | class RoleCloneView(RoleCreateView): 54 | """Clone Role view""" 55 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """Sphinx documentation generator configuration file. 2 | 3 | The full set of configuration options is listed on the Sphinx website: 4 | http://sphinx-doc.org/config.html 5 | 6 | """ 7 | 8 | import os 9 | import sys 10 | 11 | 12 | def skip_data(app, what, name, obj, skip, options): 13 | """Skip double generating docs for airgun.settings""" 14 | if what == 'data' and name == 'airgun.settings': 15 | return True 16 | return None 17 | 18 | 19 | def setup(app): 20 | app.connect('autoapi-skip-member', skip_data) 21 | 22 | 23 | # Add the AirGun root directory to the system path. This allows references 24 | # such as :mod:`airgun.browser` to be processed correctly. 25 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir))) 26 | 27 | # Project Information --------------------------------------------------------- 28 | 29 | project = 'AirGun' 30 | copyright = '2018, Andrii Balakhtar' 31 | version = '0.0.1' 32 | release = version 33 | 34 | # General Configuration ------------------------------------------------------- 35 | 36 | extensions = [ 37 | 'sphinx.ext.autodoc', 38 | 'sphinx.ext.intersphinx', 39 | 'sphinx.ext.napoleon', 40 | 'sphinxcontrib.spelling', 41 | 'autoapi.extension', 42 | ] 43 | autodoc_inherit_docstrings = False 44 | autoapi_dirs = ['../airgun'] 45 | autoapi_keep_files = True 46 | autoapi_options = [ 47 | 'members', 48 | 'undoc-members', 49 | 'private-members', 50 | 'imported-members', 51 | 'show-module-summary', 52 | 'special-members', 53 | ] 54 | source_suffix = '.rst' 55 | master_doc = 'index' 56 | exclude_patterns = ['_build'] 57 | 58 | intersphinx_mapping = { 59 | 'python': ('http://docs.python.org/3.6', None), 60 | # 'widgetastic': 61 | # ('http://widgetasticcore.readthedocs.io/en/latest/', None), 62 | # 'navmazing': ('http://navmazing.readthedocs.io/en/latest/', None), 63 | } 64 | spelling_word_list_filename = 'spelling_wordlist.txt' 65 | spelling_show_suggestions = True 66 | -------------------------------------------------------------------------------- /airgun/entities/containerimagetag.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.navigation import NavigateStep, navigator 3 | from airgun.utils import retry_navigation 4 | from airgun.views.containerimagetag import ( 5 | ContainerImageTagDetailsView, 6 | ContainerImageTagsView, 7 | ) 8 | 9 | 10 | class ContainerImageTagEntity(BaseEntity): 11 | endpoint_path = '/docker_tags' 12 | 13 | def search(self, value): 14 | """Search for specific Container Image Tag 15 | 16 | :param value: search query to type into search field 17 | :return: container image tag that match 18 | """ 19 | view = self.navigate_to(self, 'All') 20 | return view.search(value) 21 | 22 | def read(self, entity_name, widget_names=None): 23 | """Reads details of specific Container Image Tag 24 | 25 | :param entity_name: name of Container Image Tag 26 | :return: dict with properties of Container Image Tag 27 | """ 28 | view = self.navigate_to(self, 'Details', entity_name=entity_name) 29 | return view.read(widget_names=widget_names) 30 | 31 | 32 | @navigator.register(ContainerImageTagEntity, 'All') 33 | class ShowAllContainerImageTags(NavigateStep): 34 | """Navigate to All Container Image Tags screen.""" 35 | 36 | VIEW = ContainerImageTagsView 37 | 38 | @retry_navigation 39 | def step(self, *args, **kwargs): 40 | self.view.menu.select('Content', 'Content Types', 'Container Image Tags') 41 | 42 | 43 | @navigator.register(ContainerImageTagEntity, 'Details') 44 | class ContainerImageTagDetails(NavigateStep): 45 | """Navigate to Container Image Tag details page 46 | 47 | Args: 48 | entity_name: name of Container Image Tag 49 | """ 50 | 51 | VIEW = ContainerImageTagDetailsView 52 | 53 | def prerequisite(self, *args, **kwargs): 54 | return self.navigate_to(self.obj, 'All') 55 | 56 | def step(self, *args, **kwargs): 57 | entity_name = kwargs.get('entity_name') 58 | self.parent.search(entity_name) 59 | self.parent.table.row(name=entity_name)['Name'].widget.click() 60 | -------------------------------------------------------------------------------- /.github/workflows/prt_labels.yml: -------------------------------------------------------------------------------- 1 | name: Remove the PRT label, for the new commit 2 | 3 | on: 4 | pull_request: 5 | types: ["synchronize"] 6 | 7 | jobs: 8 | prt_labels_remover: 9 | name: remove the PRT label when amendments or new commits added to PR 10 | runs-on: ubuntu-latest 11 | if: "(contains(github.event.pull_request.labels.*.name, 'PRT-Passed') || contains(github.event.pull_request.labels.*.name, 'PRT-Failed'))" 12 | steps: 13 | - name: Avoid the race condition as PRT result will be cleaned 14 | run: | 15 | echo "Avoiding the race condition if prt result will be cleaned" && sleep 60 16 | 17 | - name: Fetch the PRT status 18 | id: prt 19 | uses: omkarkhatavkar/wait-for-status-checks@main 20 | with: 21 | ref: ${{ github.head_ref }} 22 | context: 'Robottelo-Runner' 23 | wait-interval: 2 24 | count: 5 25 | 26 | - name: remove the PRT Passed/Failed label, for new commit 27 | if: always() && ${{steps.prt.outputs.result}} == 'not_found' 28 | uses: actions/github-script@v8 29 | with: 30 | github-token: ${{ secrets.CHERRYPICK_PAT }} 31 | script: | 32 | const prNumber = '${{ github.event.number }}'; 33 | const issue = await github.rest.issues.get({ 34 | owner: context.repo.owner, 35 | repo: context.repo.repo, 36 | issue_number: prNumber, 37 | }); 38 | const labelsToRemove = ['PRT-Failed', 'PRT-Passed']; 39 | const labelsToRemoveFiltered = labelsToRemove.filter(label => issue.data.labels.some(({ name }) => name === label)); 40 | if (labelsToRemoveFiltered.length > 0) { 41 | await Promise.all(labelsToRemoveFiltered.map(async label => { 42 | await github.rest.issues.removeLabel({ 43 | issue_number: prNumber, 44 | owner: context.repo.owner, 45 | repo: context.repo.repo, 46 | name: label 47 | }); 48 | })); 49 | } 50 | -------------------------------------------------------------------------------- /airgun/views/media.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Table, Text, TextInput, View 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import BaseLoggedInView, SatTab, SearchableViewMixinPF4 5 | from airgun.widgets import FilteredDropdown, MultiSelect 6 | 7 | 8 | class MediumView(BaseLoggedInView, SearchableViewMixinPF4): 9 | title = Text("//h1[normalize-space(.)='Installation Media']") 10 | new = Text("//a[contains(@href, '/media/new')]") 11 | table = Table( 12 | './/table', 13 | column_widgets={ 14 | 'Name': Text('./a'), 15 | 'Actions': Text('.//a[@data-method="delete"]'), 16 | }, 17 | ) 18 | 19 | @property 20 | def is_displayed(self): 21 | return self.browser.wait_for_element(self.title, exception=False) is not None 22 | 23 | 24 | class MediaCreateView(BaseLoggedInView): 25 | breadcrumb = BreadCrumb() 26 | submit = Text('//input[@name="commit"]') 27 | 28 | @property 29 | def is_displayed(self): 30 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 31 | return ( 32 | breadcrumb_loaded 33 | and self.breadcrumb.locations[0] == 'Installation Media' 34 | and self.breadcrumb.read() == 'Create Medium' 35 | ) 36 | 37 | @View.nested 38 | class medium(SatTab): 39 | name = TextInput(id='medium_name') 40 | path = TextInput(id='medium_path') 41 | os_family = FilteredDropdown(id='medium_os_family') 42 | 43 | @View.nested 44 | class locations(SatTab): 45 | resources = MultiSelect(id='ms-medium_location_ids') 46 | 47 | @View.nested 48 | class organizations(SatTab): 49 | resources = MultiSelect(id='ms-medium_organization_ids') 50 | 51 | 52 | class MediaEditView(MediaCreateView): 53 | @property 54 | def is_displayed(self): 55 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 56 | return ( 57 | breadcrumb_loaded 58 | and self.breadcrumb.locations[0] == 'Installation Media' 59 | and self.breadcrumb.read().startswith('Edit ') 60 | ) 61 | -------------------------------------------------------------------------------- /airgun/entities/rhai/inventory.py: -------------------------------------------------------------------------------- 1 | from wait_for import wait_for 2 | 3 | from airgun.entities.base import BaseEntity 4 | from airgun.entities.rhai.base import InsightsNavigateStep 5 | from airgun.navigation import NavigateStep, navigator 6 | from airgun.views.rhai import InventoryAllHosts, InventoryHostDetails 7 | 8 | 9 | class InventoryHostEntity(BaseEntity): 10 | endpoint_path = '/redhat_access/insights/inventory' 11 | 12 | @property 13 | def total_systems(self): 14 | """Get number of all systems.""" 15 | view = self.navigate_to(self, 'All') 16 | return view.systems_count.text.split()[0] 17 | 18 | def search(self, host_name): 19 | """Search a certain host.""" 20 | view = self.navigate_to(self, 'All') 21 | view.search.fill(host_name) 22 | return view.table 23 | 24 | def read(self, entity_name, widget_names=None): 25 | """Read host details, optionally read only the widgets in widget_names.""" 26 | view = self.navigate_to(self, 'Details', entity_name=entity_name) 27 | wait_for(lambda: view.is_displayed) 28 | values = view.read(widget_names=widget_names) 29 | # close the view dialog, as will break next entities navigation 30 | view.close.click() 31 | return values 32 | 33 | 34 | @navigator.register(InventoryHostEntity, 'All') 35 | class AllHosts(InsightsNavigateStep): 36 | """Navigate to Insights Inventory screen.""" 37 | 38 | VIEW = InventoryAllHosts 39 | 40 | def step(self, *args, **kwargs): 41 | self.view.menu.select('Insights', 'Inventory') 42 | 43 | 44 | @navigator.register(InventoryHostEntity, 'Details') 45 | class HostDetails(NavigateStep): 46 | """Navigate to Insights Inventory screen. 47 | 48 | Args: 49 | entity_name: hostname 50 | """ 51 | 52 | VIEW = InventoryHostDetails 53 | 54 | def prerequisite(self, *args, **kwargs): 55 | return self.navigate_to(self.obj, 'All') 56 | 57 | def step(self, *args, **kwargs): 58 | entity_name = kwargs.get('entity_name') 59 | self.parent.search.fill(entity_name) 60 | self.parent.table.row_by_cell_or_widget_value('System Name', entity_name)[ 61 | 'System Name' 62 | ].widget.click() 63 | -------------------------------------------------------------------------------- /airgun/views/containerimagetag.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Text, View 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import ( 5 | BaseLoggedInView, 6 | ReadOnlyEntry, 7 | SatTab, 8 | SearchableViewMixin, 9 | TaskDetailsView, 10 | ) 11 | from airgun.widgets import SatTable 12 | 13 | 14 | class ContainerImageTagsView(BaseLoggedInView, SearchableViewMixin): 15 | title = Text("//h2[contains(., 'Container Image Tags')]") 16 | table = SatTable('.//table', column_widgets={'Name': Text('./a')}) 17 | 18 | @property 19 | def is_displayed(self): 20 | return self.browser.wait_for_element(self.title, exception=False) is not None 21 | 22 | 23 | class ContainerImageTagDetailsView(TaskDetailsView): 24 | breadcrumb = BreadCrumb() 25 | BREADCRUMB_LENGTH = 2 26 | 27 | @property 28 | def is_displayed(self): 29 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 30 | return ( 31 | breadcrumb_loaded 32 | and self.breadcrumb.locations[0] == 'Container Image Tags' 33 | and len(self.breadcrumb.locations) >= self.BREADCRUMB_LENGTH 34 | ) 35 | 36 | @View.nested 37 | class details(SatTab): 38 | product = ReadOnlyEntry(name='Product') 39 | schema = ReadOnlyEntry(name='Schema Version') 40 | manifest_type = ReadOnlyEntry(name='Manifest Type') 41 | digest = ReadOnlyEntry(name='Digest') 42 | 43 | @View.nested 44 | class lce(SatTab): 45 | TAB_NAME = 'Lifecycle Environments' 46 | table = SatTable( 47 | './/table', 48 | column_widgets={ 49 | 'Environment': Text('./a'), 50 | 'Content View Version': Text('./a'), 51 | 'Published At': Text('./a'), 52 | }, 53 | ) 54 | 55 | @View.nested 56 | class repos(SatTab): 57 | TAB_NAME = 'Repositories' 58 | table = SatTable( 59 | './/table', 60 | column_widgets={ 61 | 'Name': Text('./a'), 62 | 'Product': Text('./a'), 63 | 'Content View': Text('./a'), 64 | 'Last Sync': Text('./a'), 65 | }, 66 | ) 67 | -------------------------------------------------------------------------------- /airgun/entities/smart_class_parameter.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.navigation import NavigateStep, navigator 3 | from airgun.utils import retry_navigation 4 | from airgun.views.smart_class_parameter import ( 5 | SmartClassParameterEditView, 6 | SmartClassParametersView, 7 | ) 8 | 9 | 10 | class SmartClassParameterEntity(BaseEntity): 11 | endpoint_path = '/foreman_puppet/puppetclass_lookup_keys' 12 | 13 | def search(self, value): 14 | """Search for smart class parameter entity and return table row that 15 | contains that entity 16 | """ 17 | view = self.navigate_to(self, 'All') 18 | return view.search(value) 19 | 20 | def read(self, entity_name, widget_names=None): 21 | """Read all values for existing smart class parameter entity""" 22 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 23 | return view.read(widget_names=widget_names) 24 | 25 | def update(self, entity_name, values): 26 | """Update specific smart class parameter values""" 27 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 28 | view.fill(values) 29 | view.submit.click() 30 | view.flash.assert_no_error() 31 | view.flash.dismiss() 32 | 33 | 34 | @navigator.register(SmartClassParameterEntity, 'All') 35 | class ShowAllSmartClassParameters(NavigateStep): 36 | """Navigate to All Smart Class Parameter screen.""" 37 | 38 | VIEW = SmartClassParametersView 39 | 40 | @retry_navigation 41 | def step(self, *args, **kwargs): 42 | self.view.menu.select('Configure', 'Puppet ENC', 'Smart Class Parameters') 43 | 44 | 45 | @navigator.register(SmartClassParameterEntity, 'Edit') 46 | class EditSmartClassParameter(NavigateStep): 47 | """Navigate to Edit Smart Class Parameter screen. 48 | 49 | Args: 50 | entity_name: name of smart class parameter 51 | """ 52 | 53 | VIEW = SmartClassParameterEditView 54 | 55 | def prerequisite(self, *args, **kwargs): 56 | return self.navigate_to(self.obj, 'All') 57 | 58 | def step(self, *args, **kwargs): 59 | entity_name = kwargs.get('entity_name') 60 | self.parent.search(entity_name) 61 | self.parent.table.row(parameter=entity_name)['Parameter'].widget.click() 62 | -------------------------------------------------------------------------------- /airgun/views/hardware_model.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Text, TextInput 2 | from widgetastic_patternfly import BreadCrumb 3 | from widgetastic_patternfly5.ouia import PatternflyTable as PF5OUIATable 4 | 5 | from airgun.views.common import BaseLoggedInView, SearchableViewMixinPF4 6 | from airgun.views.host_new import MenuToggleButtonMenu 7 | from airgun.widgets import Pf5ConfirmationDialog 8 | 9 | 10 | class DeleteHardwareModelDialog(Pf5ConfirmationDialog): 11 | confirm_dialog = Text(".//button[contains(normalize-space(.),'Delete')]") 12 | cancel_dialog = Text(".//button[normalize-space(.)='Cancel']") 13 | 14 | 15 | class HardwareModelsView(BaseLoggedInView, SearchableViewMixinPF4): 16 | delete_dialog = DeleteHardwareModelDialog() 17 | title = Text("//h1[normalize-space(.)='Hardware models']") 18 | new = Text("//a[contains(@href, '/models/new')]") 19 | table = PF5OUIATable( 20 | component_id='table', 21 | column_widgets={ 22 | 'Name': Text('.//a'), 23 | 4: MenuToggleButtonMenu(), 24 | }, 25 | ) 26 | 27 | @property 28 | def is_displayed(self): 29 | return self.browser.wait_for_element(self.title, exception=False) is not None 30 | 31 | 32 | class HardwareModelCreateView(BaseLoggedInView): 33 | breadcrumb = BreadCrumb() 34 | name = TextInput(id='model_name') 35 | hardware_model = TextInput(id='model_hardware_model') 36 | vendor_class = TextInput(id='model_vendor_class') 37 | info = TextInput(id='model_info') 38 | submit = Text('//input[@name="commit"]') 39 | 40 | @property 41 | def is_displayed(self): 42 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 43 | return ( 44 | breadcrumb_loaded 45 | and self.breadcrumb.locations[0] == 'Hardware Models' 46 | and self.breadcrumb.read() == 'Create Model' 47 | ) 48 | 49 | 50 | class HardwareModelEditView(HardwareModelCreateView): 51 | @property 52 | def is_displayed(self): 53 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 54 | return ( 55 | breadcrumb_loaded 56 | and self.breadcrumb.locations[0] == 'Hardware Models' 57 | and self.breadcrumb.read().startswith('Edit ') 58 | ) 59 | -------------------------------------------------------------------------------- /airgun/views/ansible_role.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Checkbox, Table, Text 2 | from widgetastic_patternfly import BreadCrumb 3 | from widgetastic_patternfly5 import ( 4 | Button as PF5button, 5 | CompactPagination as PF5CompactPagination, 6 | Pagination as PF5Pagination, 7 | PatternflyTable as PF5PatternflyTable, 8 | ) 9 | 10 | from airgun.views.common import BaseLoggedInView, SearchableViewMixin 11 | from airgun.widgets import ActionsDropdown 12 | 13 | 14 | class AnsibleRolesView(BaseLoggedInView, SearchableViewMixin): 15 | """Main Ansible Roles view. Prior to importing any roles, only the import_button 16 | is present, without the search widget or table. 17 | """ 18 | 19 | title = Text("//h1[contains(normalize-space(.),'Ansible Roles')]") 20 | import_button = Text("//a[contains(@href, '/ansible_roles/import')]") 21 | submit = PF5button('Submit') 22 | total_imported_roles = Text("//span[@class='pf-c-options-menu__toggle-text']//b[2]") 23 | table = Table( 24 | './/table', 25 | column_widgets={ 26 | 'Actions': ActionsDropdown("./div[contains(@class, 'btn-group')]"), 27 | }, 28 | ) 29 | pagination = PF5Pagination() 30 | 31 | @property 32 | def is_displayed(self): 33 | return self.title.is_displayed and self.import_button.is_displayed 34 | 35 | 36 | class AnsibleRolesImportView(BaseLoggedInView): 37 | """View while selecting Ansible roles to import.""" 38 | 39 | breadcrumb = BreadCrumb() 40 | total_available_roles = Text("//span[@class='pf-v5-c-menu-toggle__text']/b[2]") 41 | select_all = Checkbox(locator="//input[@id='select-all']") 42 | table = PF5PatternflyTable( 43 | component_id='ansible-roles-and-variables-table', 44 | column_widgets={ 45 | 0: Checkbox(locator='.//input[@type="checkbox"]'), 46 | }, 47 | ) 48 | roles = Text("//table[contains(@class, 'pf-v5-c-table')]") 49 | dropdown = Text("//button[contains(@class, 'pf-v5-c-menu-toggle')]") 50 | max_per_pg = Text("//ul[contains(@class, 'pf-v5-c-menu__list')]/li[6]") 51 | pagination = PF5CompactPagination() 52 | submit = PF5button('Submit') 53 | cancel = PF5button('Cancel') 54 | 55 | @property 56 | def is_displayed(self): 57 | return ( 58 | self.breadcrumb.locations[0] == 'Roles' 59 | and self.breadcrumb.read() == 'Changed Ansible roles' 60 | ) 61 | -------------------------------------------------------------------------------- /airgun/views/puppet_class.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Text, TextInput, View 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import BaseLoggedInView, SatTab, SearchableViewMixin 5 | from airgun.views.smart_class_parameter import SmartClassParameterContent 6 | from airgun.widgets import FilteredDropdown, ItemsList, MultiSelect, SatTable 7 | 8 | 9 | class PuppetClassesView(BaseLoggedInView, SearchableViewMixin): 10 | title = Text("//h1[normalize-space(.)='Puppet Classes']") 11 | import_environments = Text("//a[contains(@href, '/import_environments')]") 12 | table = SatTable( 13 | './/table', 14 | column_widgets={ 15 | 'Name': Text('./a'), 16 | 'Actions': Text('.//a[@data-method="delete"]'), 17 | }, 18 | ) 19 | 20 | @property 21 | def is_displayed(self): 22 | return self.browser.wait_for_element(self.title, exception=False) is not None 23 | 24 | 25 | class PuppetClassDetailsView(BaseLoggedInView): 26 | breadcrumb = BreadCrumb() 27 | submit = Text('//input[@name="commit"]') 28 | 29 | @property 30 | def is_displayed(self): 31 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 32 | return ( 33 | breadcrumb_loaded 34 | and self.breadcrumb.locations[0] == 'Puppetclasses' 35 | and self.breadcrumb.read().startswith('Edit Puppet Class ') 36 | ) 37 | 38 | @View.nested 39 | class puppet_class(SatTab): 40 | TAB_NAME = 'Puppet Class' 41 | # Name field is disabled by default 42 | name = TextInput(id='puppetclass_name') 43 | # Puppet environment field is disabled by default 44 | puppet_environment = TextInput(id='puppetclass_environments') 45 | host_group = MultiSelect(id='ms-puppetclass_hostgroup_ids') 46 | 47 | @View.nested 48 | class smart_class_parameter(SatTab): 49 | TAB_NAME = 'Smart Class Parameter' 50 | filter = TextInput(locator="//input[@placeholder='Filter by name']") 51 | environment_filter = FilteredDropdown(id='environment_filter') 52 | parameter_list = ItemsList( 53 | "//div[@id='smart_class_param']//ul[contains(@class, 'smart-var-tabs')]" 54 | ) 55 | parameter = SmartClassParameterContent( 56 | locator="//div[@id='smart_class_param']//div[@class='tab-pane fields active']" 57 | ) 58 | -------------------------------------------------------------------------------- /airgun/views/oscapcontent.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import FileInput, Text, TextInput, View 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import BaseLoggedInView, SatTab, SearchableViewMixinPF4 5 | from airgun.widgets import ActionsDropdown, MultiSelect, SatTable 6 | 7 | 8 | class SCAPContentsView(BaseLoggedInView, SearchableViewMixinPF4): 9 | title = Text("//h1[normalize-space(.)='SCAP Contents']") 10 | new = Text("//a[contains(@href, 'scap_contents/new')]") 11 | table = SatTable( 12 | './/table', 13 | column_widgets={ 14 | 'Title': Text('./a'), 15 | 'Actions': ActionsDropdown("./div[contains(@class, 'btn-group')]"), 16 | }, 17 | ) 18 | 19 | @property 20 | def is_displayed(self): 21 | return self.browser.wait_for_element(self.title, exception=False) is not None 22 | 23 | 24 | class SCAPContentCreateView(BaseLoggedInView): 25 | create_form = Text("//form[@id='new_scap_content']") 26 | submit = Text('//input[@name="commit"]') 27 | cancel = Text('//a[normalize-space(.)="Cancel"]') 28 | 29 | @View.nested 30 | class file_upload(SatTab): 31 | TAB_NAME = 'File Upload' 32 | title = TextInput(id='scap_content_title') 33 | scap_file = FileInput(id='scap_content_scap_file') 34 | 35 | @View.nested 36 | class locations(SatTab): 37 | resources = MultiSelect(id='ms-scap_content_location_ids') 38 | 39 | @View.nested 40 | class organizations(SatTab): 41 | resources = MultiSelect(id='ms-scap_content_organization_ids') 42 | 43 | @property 44 | def is_displayed(self): 45 | return self.browser.wait_for_element(self.create_form, exception=False) is not None 46 | 47 | 48 | class SCAPContentEditView(SCAPContentCreateView): 49 | scap_file_name = Text('//div[@class="col-md-4"]/b') 50 | breadcrumb = BreadCrumb() 51 | 52 | @View.nested 53 | class file_upload(SatTab): 54 | TAB_NAME = 'File Upload' 55 | title = TextInput(id='scap_content_title') 56 | uploaded_scap_file = Text(locator="//label[@for='scap_file']/following-sibling::div/b") 57 | scap_file = FileInput(id='scap_content_scap_file') 58 | 59 | @property 60 | def is_displayed(self): 61 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 62 | return breadcrumb_loaded and self.breadcrumb.locations[0] == 'Scap Contents' 63 | -------------------------------------------------------------------------------- /airgun/entities/config_report.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.navigation import NavigateStep, navigator 3 | from airgun.utils import retry_navigation 4 | from airgun.views.config_report import ConfigReportDetailsView, ConfigReportsView 5 | 6 | 7 | class ConfigReportEntity(BaseEntity): 8 | endpoint_path = '/config_reports' 9 | 10 | def read(self, widget_names=None, host_name=None): 11 | """Read all values for generated Config Reports""" 12 | view = self.navigate_to(self, 'All') 13 | if host_name: 14 | view.search(host_name) 15 | return view.read(widget_names=widget_names) 16 | 17 | def search(self, hostname): 18 | """Search for specific Config report""" 19 | view = self.navigate_to(self, 'Report Details', host_name=hostname) 20 | return view.read() 21 | 22 | def export(self, host_name=None): 23 | """Export a Config report. 24 | 25 | :return str: path to saved file 26 | """ 27 | view = self.navigate_to(self, 'All') 28 | if host_name: 29 | view.search(host_name) 30 | view.export.click() 31 | return self.browser.save_downloaded_file() 32 | 33 | def delete(self, host_name): 34 | """Delete a Config report""" 35 | view = self.navigate_to(self, 'All') 36 | view.search(host_name) 37 | view.table.row()['Actions'].widget.click() 38 | self.browser.handle_alert() 39 | view.flash.assert_no_error() 40 | view.flash.dismiss() 41 | 42 | 43 | @navigator.register(ConfigReportEntity, 'All') 44 | class ShowAllConfigReports(NavigateStep): 45 | """Navigate to all Config Report screen.""" 46 | 47 | VIEW = ConfigReportsView 48 | 49 | @retry_navigation 50 | def step(self, *args, **kwargs): 51 | self.view.menu.select('Monitor', 'Reports', 'Config Management') 52 | 53 | 54 | @navigator.register(ConfigReportEntity, 'Report Details') 55 | class ConfigReportStatus(NavigateStep): 56 | """Navigate to Config Report details screen. 57 | 58 | Args:host_name: name of the host to which job was applied 59 | """ 60 | 61 | VIEW = ConfigReportDetailsView 62 | 63 | def prerequisite(self, *args, **kwargs): 64 | return self.navigate_to(self.obj, 'All') 65 | 66 | def step(self, *args, **kwargs): 67 | self.parent.search(f'host = {kwargs.get("host_name")}') 68 | self.parent.table.row()['Last report'].widget.click() 69 | -------------------------------------------------------------------------------- /airgun/entities/base.py: -------------------------------------------------------------------------------- 1 | from widgetastic.exceptions import NoSuchElementException 2 | 3 | from airgun.exceptions import DisabledWidgetError 4 | from airgun.helpers.base import BaseEntityHelper 5 | from airgun.views.common import BookmarkCreateView 6 | 7 | 8 | class BaseEntity: 9 | HELPER_CLASS = BaseEntityHelper 10 | 11 | def __init__(self, browser): 12 | self.browser = browser 13 | self.session = browser.extra_objects['session'] 14 | self.navigate_to = self.session.navigator.navigate 15 | self._helper = self.HELPER_CLASS(self) 16 | 17 | @property 18 | def helper(self): 19 | return self._helper 20 | 21 | def create_bookmark(self, values, search_query=None): 22 | """Create a bookmark. 23 | 24 | :param dict values: dictionary with keys 'name', 'query', 'public' 25 | :param str optional search_query: a query to type into searchbox if 26 | needed. Such query will be automatically populated as 'query' field 27 | for bookmark 28 | """ 29 | # not using separate navigator step here not to have to register 30 | # navigate step for every single entity 31 | view = self.navigate_to(self, 'All') 32 | if not hasattr(view, 'searchbox'): 33 | raise KeyError(f'{self.__class__.__name__} does not have searchbox') 34 | if not view.searchbox.actions.is_displayed: 35 | raise NoSuchElementException( 36 | f'Unable to create a bookmark - {self.__class__.__name__} ' 37 | 'has a searchbox with no actions dropdown' 38 | ) 39 | if search_query: 40 | view.searchbox.search_field.fill(search_query) 41 | view.searchbox.actions.fill('Bookmark this search') 42 | view = BookmarkCreateView(self.browser) 43 | view.fill(values) 44 | if not view.submit.is_enabled: 45 | message = view.error_message.text 46 | view.cancel.click() 47 | raise DisabledWidgetError(message) 48 | view.submit.click() 49 | view.flash.assert_no_error() 50 | view.flash.dismiss() 51 | 52 | def search_menu(self, query: str) -> list[str]: 53 | """Perform a search of the vertical navigation menu. 54 | 55 | :param str query: search query for the vertical navigation menu 56 | :return list[str]: search results 57 | """ 58 | view = self.navigate_to(self, 'All') 59 | return view.menu_search.search(query) 60 | -------------------------------------------------------------------------------- /airgun/views/domain.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Table, Text, TextInput, View 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import BaseLoggedInView, SatTab, SearchableViewMixinPF4 5 | from airgun.widgets import CustomParameter, FilteredDropdown, MultiSelect 6 | 7 | 8 | class DomainListView(BaseLoggedInView, SearchableViewMixinPF4): 9 | """List of all domains.""" 10 | 11 | title = Text('//*[(self::h1 or self::h5) and normalize-space(.)="Domains"]') 12 | new = Text('//a[normalize-space(.)="Create Domain"]') 13 | table = Table( 14 | './/table', 15 | column_widgets={ 16 | 'Description': Text('./a'), 17 | 'Hosts': Text('./a'), 18 | 'Actions': Text(".//a[@data-method='delete']"), 19 | }, 20 | ) 21 | 22 | @property 23 | def is_displayed(self): 24 | return self.browser.wait_for_element(self.title, exception=False) is not None 25 | 26 | 27 | class DomainCreateView(BaseLoggedInView): 28 | breadcrumb = BreadCrumb() 29 | submit_button = Text(".//input[@name='commit']") 30 | cancel_button = Text(".//a[@href='/domains']") 31 | 32 | @View.nested 33 | class domain(SatTab): 34 | dns_domain = TextInput(id='domain_name') 35 | full_name = TextInput(id='domain_fullname') 36 | dns_capsule = FilteredDropdown(id='domain_dns_id') 37 | 38 | @View.nested 39 | class parameters(SatTab): 40 | params = CustomParameter(id='global_parameters_table') 41 | 42 | @View.nested 43 | class locations(SatTab): 44 | multiselect = MultiSelect(id='ms-domain_location_ids') 45 | 46 | @View.nested 47 | class organizations(SatTab): 48 | multiselect = MultiSelect(id='ms-domain_organization_ids') 49 | 50 | @property 51 | def is_displayed(self): 52 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 53 | return ( 54 | breadcrumb_loaded 55 | and self.breadcrumb.locations[0] == 'Domains' 56 | and self.breadcrumb.read() == 'Create Domain' 57 | ) 58 | 59 | 60 | class DomainEditView(DomainCreateView): 61 | @property 62 | def is_displayed(self): 63 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 64 | return ( 65 | breadcrumb_loaded 66 | and self.breadcrumb.locations[0] == 'Domains' 67 | and self.breadcrumb.read().startswith('Edit ') 68 | ) 69 | -------------------------------------------------------------------------------- /airgun/entities/sync_status.py: -------------------------------------------------------------------------------- 1 | from wait_for import wait_for 2 | 3 | from airgun.entities.base import BaseEntity 4 | from airgun.navigation import NavigateStep, navigator 5 | from airgun.utils import retry_navigation 6 | from airgun.views.sync_status import SyncStatusView 7 | 8 | 9 | class SyncStatusEntity(BaseEntity): 10 | endpoint_path = '/katello/sync_management' 11 | 12 | def read(self, widget_names=None, active_only=False): 13 | """Read all widgets at Sync status entity""" 14 | view = self.navigate_to(self, 'All') 15 | view.active_only.fill(active_only) 16 | return view.read(widget_names=widget_names) 17 | 18 | def synchronize(self, repository_paths, synchronous=True, timeout=3600): 19 | """Synchronize repositories 20 | 21 | :param repository_paths: A list of repositories to synchronize 22 | where each element of the list is path to repository represented by a list or tuple. 23 | :param synchrounous: bool if to wait for all repos sync, defaults to True. 24 | :param timeout: time to wait for all repositories to be synchronized. 25 | 26 | Usage:: 27 | 28 | synchronize([('product1', 'repo1'), 29 | ('product1', 'repo2'), 30 | ('product2', 'repo2'), 31 | ('Red Hat Enterprise Linux Server', '7.5', 'x86_64', 32 | 'Red Hat Enterprise Linux 7 Server RPMs x86_64 7.5'), 33 | ('Red Hat Satellite Capsule', 34 | 'Red Hat Satellite Capsule 6.2 for RHEL 7 Server RPMs x86_64')]) 35 | 36 | :return: the results text in RESULT columns 37 | """ 38 | view = self.navigate_to(self, 'All') 39 | repo_nodes = [view.table.get_node_from_path(repo_path) for repo_path in repository_paths] 40 | for repo_node in repo_nodes: 41 | repo_node.fill(True) 42 | view.synchronize_now.click() 43 | if synchronous: 44 | wait_for( 45 | lambda: all(node.progress is None for node in repo_nodes), 46 | timeout=timeout, 47 | delay=5, 48 | logger=view.logger, 49 | ) 50 | 51 | return [node.result for node in repo_nodes] 52 | 53 | 54 | @navigator.register(SyncStatusEntity, 'All') 55 | class ShowAllHostCollections(NavigateStep): 56 | VIEW = SyncStatusView 57 | 58 | @retry_navigation 59 | def step(self, *args, **kwargs): 60 | self.view.menu.select('Content', 'Sync Status') 61 | -------------------------------------------------------------------------------- /airgun/views/computeprofile.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Table, Text, TextInput 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import BaseLoggedInView, SearchableViewMixinPF4 5 | from airgun.widgets import ActionsDropdown 6 | 7 | 8 | class ComputeProfilesView(BaseLoggedInView, SearchableViewMixinPF4): 9 | title = Text('//*[(self::h1 or self::h5) and normalize-space(.)="Compute Profiles"]') 10 | new = Text('//a[normalize-space(.)="Create Compute Profile"]') 11 | table = Table( 12 | './/table', 13 | column_widgets={ 14 | 'Name': Text('./a'), 15 | 'Actions': ActionsDropdown("./div[contains(@class, 'btn-group')]"), 16 | }, 17 | ) 18 | 19 | @property 20 | def is_displayed(self): 21 | return self.browser.wait_for_element(self.title, exception=False) is not None 22 | 23 | 24 | class ComputeProfileCreateView(BaseLoggedInView): 25 | breadcrumb = BreadCrumb() 26 | name = TextInput(locator=".//input[@id='compute_profile_name']") 27 | submit = Text('//input[@name="commit"]') 28 | 29 | @property 30 | def is_displayed(self): 31 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 32 | return ( 33 | breadcrumb_loaded 34 | and self.breadcrumb.locations[0] == 'Compute Profiles' 35 | and self.breadcrumb.read() == 'Create Compute Profile' 36 | ) 37 | 38 | 39 | class ComputeProfileDetailView(BaseLoggedInView): 40 | breadcrumb = BreadCrumb() 41 | table = Table( 42 | './/table', 43 | column_widgets={ 44 | 'Compute Resource': Text('./a'), 45 | }, 46 | ) 47 | 48 | @property 49 | def is_displayed(self): 50 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 51 | return ( 52 | breadcrumb_loaded 53 | and self.breadcrumb.locations[0] == 'Compute Profiles' 54 | and self.breadcrumb.read() != 'Create Compute Profile' 55 | and self.breadcrumb.read() != 'Edit Compute Profile' 56 | ) 57 | 58 | 59 | class ComputeProfileRenameView(ComputeProfileCreateView): 60 | @property 61 | def is_displayed(self): 62 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 63 | return ( 64 | breadcrumb_loaded 65 | and self.breadcrumb.locations[0] == 'Compute profiles' 66 | and self.breadcrumb.read() == 'Edit Compute profile' 67 | ) 68 | -------------------------------------------------------------------------------- /airgun/entities/ansible_variable.py: -------------------------------------------------------------------------------- 1 | from navmazing import NavigateToSibling 2 | 3 | from airgun.entities.base import BaseEntity 4 | from airgun.navigation import NavigateStep, navigator 5 | from airgun.views.ansible_variable import AnsibleVariablesView, NewAnsibleVariableView 6 | 7 | 8 | class AnsibleVariablesEntity(BaseEntity): 9 | """Main Ansible variables entity""" 10 | 11 | endpoint_path = '/ansible/ansible_variables' 12 | 13 | def search(self, value): 14 | """Search for existing Ansible variable""" 15 | view = self.navigate_to(self, 'All') 16 | view.search(value) 17 | return view.table.read() 18 | 19 | def delete(self, entity_name): 20 | """Delete Ansible variable from Satellite""" 21 | view = self.navigate_to(self, 'All') 22 | view.search(entity_name) 23 | view.table.row(name=entity_name)['Actions'].widget.click() 24 | view.dialog.confirm_dialog.click() 25 | view.flash.assert_no_error() 26 | view.flash.dismiss() 27 | 28 | def read_total_variables(self): 29 | """Returns the number of Ansible variables currently in Satellite""" 30 | view = self.navigate_to(self, 'All') 31 | return view.total_variables.read() 32 | 33 | def create(self, values): 34 | """Create a new Ansible variable with minimum inputs""" 35 | view = self.navigate_to(self, 'New') 36 | view.fill(values) 37 | view.submit.click() 38 | view.flash.assert_no_error() 39 | view.flash.dismiss() 40 | 41 | def create_with_overrides(self, values): 42 | """Create a new Ansible variable that is managed by Satellite""" 43 | view = self.navigate_to(self, 'New') 44 | view.override.fill(True) 45 | view.expand() 46 | view.matcher_section.before_fill(values) 47 | view.fill(values) 48 | view.submit.click() 49 | view.flash.assert_no_error() 50 | view.flash.dismiss() 51 | 52 | 53 | @navigator.register(AnsibleVariablesEntity, 'All') 54 | class ShowAllVariables(NavigateStep): 55 | """Navigate to Ansible Variables page""" 56 | 57 | VIEW = AnsibleVariablesView 58 | 59 | def step(self, *args, **kwargs): 60 | self.view.menu.select('Configure', 'Ansible', 'Variables') 61 | 62 | 63 | @navigator.register(AnsibleVariablesEntity, 'New') 64 | class NewAnsibleVariable(NavigateStep): 65 | """Navigate to Create Ansible Variable page""" 66 | 67 | VIEW = NewAnsibleVariableView 68 | 69 | prerequisite = NavigateToSibling('All') 70 | 71 | def step(self, *args, **kwargs): 72 | self.parent.new_variable.click() 73 | -------------------------------------------------------------------------------- /airgun/views/filter.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import ( 2 | Table, 3 | Text, 4 | TextInput, 5 | ) 6 | from widgetastic_patternfly import BreadCrumb 7 | from widgetastic_patternfly4 import Pagination as PF4Pagination 8 | 9 | from airgun.views.common import BaseLoggedInView 10 | from airgun.widgets import ( 11 | ActionsDropdown, 12 | PF4FilteredDropdown, 13 | PF4MultiSelect, 14 | Search, 15 | ) 16 | 17 | 18 | class FiltersView(BaseLoggedInView): 19 | breadcrumb = BreadCrumb() 20 | searchbox = Search() 21 | new = Text("//a[contains(@href, '/filters/new')]") 22 | table = Table( 23 | './/table', 24 | column_widgets={ 25 | 'Actions': ActionsDropdown("./div[contains(@class, 'btn-group')]"), 26 | }, 27 | ) 28 | pagination = PF4Pagination() 29 | 30 | @property 31 | def is_displayed(self): 32 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 33 | return ( 34 | breadcrumb_loaded 35 | and self.breadcrumb.locations[0] == 'Roles' 36 | and self.breadcrumb.read().endswith(' filters') 37 | ) 38 | 39 | def search(self, query): 40 | value = self.searchbox.read() 41 | role_id = [int(s) for s in value.split() if s.isdigit()] 42 | if len(role_id) > 0: 43 | query = f'role_id = {role_id[0]} and resource = "{query}"' 44 | self.searchbox.search(query) 45 | return self.table.read() 46 | 47 | 48 | class FilterDetailsView(BaseLoggedInView): 49 | breadcrumb = BreadCrumb() 50 | resource_type = PF4FilteredDropdown( 51 | locator='.//div[@data-ouia-component-id="resource-type-select"]' 52 | ) 53 | permission = PF4MultiSelect('.//div[@id="permission-duel-select"]') 54 | filter = TextInput(id='search') 55 | submit = Text('//button[@data-ouia-component-id="filters-submit-button"]') 56 | 57 | @property 58 | def is_displayed(self): 59 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 60 | return ( 61 | breadcrumb_loaded 62 | and self.breadcrumb.locations[0] == 'Roles' 63 | and self.breadcrumb.read().startswith('Edit filter for ') 64 | ) 65 | 66 | 67 | class FilterCreateView(FilterDetailsView): 68 | @property 69 | def is_displayed(self): 70 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 71 | return ( 72 | breadcrumb_loaded 73 | and self.breadcrumb.locations[0] == 'Roles' 74 | and self.breadcrumb.read() == 'Create Filter' 75 | ) 76 | -------------------------------------------------------------------------------- /airgun/views/contentcredential.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import FileInput, Select, Table, Text, TextInput, View 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import BaseLoggedInView, SatTab, SearchableViewMixin 5 | from airgun.widgets import ConfirmationDialog, EditableEntry, ReadOnlyEntry 6 | 7 | 8 | class ContentCredentialsTableView(BaseLoggedInView, SearchableViewMixin): 9 | title = Text("//h2[contains(., 'Content Credentials')]") 10 | new = Text("//button[contains(@href, '/content_credentials/new')]") 11 | table = Table('.//table', column_widgets={'Name': Text('./a')}) 12 | 13 | @property 14 | def is_displayed(self): 15 | return self.browser.wait_for_element(self.title, exception=False) is not None 16 | 17 | 18 | class ContentCredentialCreateView(BaseLoggedInView): 19 | breadcrumb = BreadCrumb() 20 | name = TextInput(id='name') 21 | content_type = Select(id='content_type') 22 | content = TextInput(name='content') 23 | upload_file = FileInput(name='file_path') 24 | submit = Text("//button[contains(@ng-click, 'handleSave')]") 25 | 26 | @property 27 | def is_displayed(self): 28 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 29 | return ( 30 | breadcrumb_loaded 31 | and self.breadcrumb.locations[0] == 'Content Credential' 32 | and self.breadcrumb.read() == 'New Content Credential' 33 | ) 34 | 35 | 36 | class ContentCredentialEditView(BaseLoggedInView): 37 | breadcrumb = BreadCrumb() 38 | remove = Text("//button[contains(., 'Remove Content Credential')]") 39 | dialog = ConfirmationDialog() 40 | 41 | @property 42 | def is_displayed(self): 43 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 44 | return ( 45 | breadcrumb_loaded 46 | and self.breadcrumb.locations[0] == 'Content Credential' 47 | and self.breadcrumb.read() != 'New Content Credential' 48 | ) 49 | 50 | @View.nested 51 | class details(SatTab): 52 | name = EditableEntry(name='Name') 53 | content_type = ReadOnlyEntry(name='Type') 54 | content = EditableEntry(name='Content') 55 | products = ReadOnlyEntry(name='Products') 56 | repos = ReadOnlyEntry(name='Repositories') 57 | 58 | @View.nested 59 | class products(SatTab, SearchableViewMixin): 60 | table = Table('.//table', column_widgets={'Name': Text('./a')}) 61 | 62 | @View.nested 63 | class repositories(SatTab, SearchableViewMixin): 64 | table = Table('.//table', column_widgets={'Name': Text('./a')}) 65 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | # CI stages to execute against Pull Requests 2 | name: Airgun - CI 3 | 4 | on: 5 | pull_request: 6 | types: ["opened", "synchronize", "reopened"] 7 | 8 | env: 9 | PYCURL_SSL_LIBRARY: openssl 10 | UV_SYSTEM_PYTHON: 1 11 | 12 | jobs: 13 | codechecks: 14 | name: Code Quality 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ['3.12', '3.13', '3.14'] 19 | steps: 20 | - name: Checkout Airgun 21 | uses: actions/checkout@v6 22 | 23 | - name: Set Up Python-${{ matrix.python-version }} 24 | uses: actions/setup-python@v6 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | 28 | - name: Install the latest version of uv and set the Python version 29 | uses: astral-sh/setup-uv@v7 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | enable-cache: true 33 | cache-dependency-glob: | 34 | **/requirements*.txt 35 | **/setup.py 36 | 37 | - name: Install Dependencies 38 | run: | 39 | sudo apt update 40 | uv pip install -U -r requirements.txt -r requirements-optional.txt 41 | 42 | - name: Analysis (git diff) 43 | if: failure() 44 | run: git diff 45 | 46 | - name: Docs Build 47 | run: | 48 | make docs-html 49 | 50 | robottelo-cross-check: 51 | name: Robottelo installation cross-check 52 | runs-on: ubuntu-latest 53 | needs: codechecks 54 | strategy: 55 | matrix: 56 | python-version: ['3.14'] 57 | steps: 58 | - name: Checkout Airgun 59 | uses: actions/checkout@v6 60 | 61 | - name: Set Up Python 62 | uses: actions/setup-python@v6 63 | with: 64 | python-version: ${{ matrix.python-version }} 65 | 66 | - name: Install the latest version of uv and set the Python version 67 | uses: astral-sh/setup-uv@v7 68 | with: 69 | python-version: ${{ matrix.python-version }} 70 | enable-cache: true 71 | cache-dependency-glob: | 72 | **/requirements*.txt 73 | **/setup.py 74 | 75 | - name: Download robottelo's requirements.txt 76 | run: | 77 | curl -s https://raw.githubusercontent.com/SatelliteQE/robottelo/$GITHUB_BASE_REF/requirements.txt -o requirements-robottelo.txt 78 | 79 | - name: Remove airgun from robottelo requirements 80 | run: | 81 | sed -i '/airgun/d' requirements-robottelo.txt 82 | 83 | - name: Robottelo Installability 84 | run: | 85 | uv pip install -U -r requirements-robottelo.txt -r requirements.txt -r requirements-optional.txt 86 | -------------------------------------------------------------------------------- /airgun/views/http_proxy.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Checkbox, Table, Text, TextInput, View 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import BaseLoggedInView, SatTab, SearchableViewMixin 5 | from airgun.widgets import MultiSelect 6 | 7 | 8 | class HTTPProxyView(BaseLoggedInView, SearchableViewMixin): 9 | title = Text('//*[(self::h1 or self::h5) and normalize-space(.)="HTTP proxies"]') 10 | new = Text('//a[normalize-space(.)="New HTTP proxy"]') 11 | table = Table( 12 | './/table', 13 | column_widgets={ 14 | 'Name': Text('./a'), 15 | 'URL': Text('./a'), 16 | 'Actions': Text(".//a[@data-method='delete']"), 17 | }, 18 | ) 19 | 20 | @property 21 | def is_displayed(self): 22 | return self.browser.wait_for_element(self.title, exception=False) is not None 23 | 24 | 25 | class HTTPProxyCreateView(BaseLoggedInView): 26 | breadcrumb = BreadCrumb() 27 | submit = Text('//input[@name="commit"]') 28 | cancel = Text('//a[normalize-space(.)="Cancel"]') 29 | test_button = Text('//a[@id="test_connection_button"]') 30 | 31 | @property 32 | def is_displayed(self): 33 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 34 | return ( 35 | breadcrumb_loaded 36 | and self.breadcrumb.locations[0] == 'HTTP proxies' 37 | and self.breadcrumb.read() == 'New HTTP proxy' 38 | ) 39 | 40 | @View.nested 41 | class http_proxy(SatTab): 42 | TAB_NAME = 'HTTP proxy' 43 | name = TextInput(id='http_proxy_name') 44 | url = TextInput(id='http_proxy_url') 45 | username = TextInput(id='http_proxy_username') 46 | disable_pass = Text('//a[@id="disable-pass-btn"]') 47 | password = TextInput(id='http_proxy_password') 48 | test_url = TextInput(id='http_proxy_test_url') 49 | test_connection = Text('//a[@id="test_connection_button"]') 50 | content_default_http_proxy = Checkbox(id='content_default_http_proxy') 51 | 52 | @View.nested 53 | class locations(SatTab): 54 | resources = MultiSelect(id='ms-http_proxy_location_ids') 55 | 56 | @View.nested 57 | class organizations(SatTab): 58 | resources = MultiSelect(id='ms-http_proxy_organization_ids') 59 | 60 | 61 | class HTTPProxyEditView(HTTPProxyCreateView): 62 | @property 63 | def is_displayed(self): 64 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 65 | return ( 66 | breadcrumb_loaded 67 | and self.breadcrumb.locations[0] == 'Http proxies' 68 | and self.breadcrumb.read().startswith('Edit ') 69 | ) 70 | -------------------------------------------------------------------------------- /airgun/entities/media.py: -------------------------------------------------------------------------------- 1 | from navmazing import NavigateToSibling 2 | 3 | from airgun.entities.base import BaseEntity 4 | from airgun.navigation import NavigateStep, navigator 5 | from airgun.utils import retry_navigation 6 | from airgun.views.media import MediaCreateView, MediaEditView, MediumView 7 | 8 | 9 | class MediaEntity(BaseEntity): 10 | endpoint_path = '/media' 11 | 12 | def create(self, values): 13 | """Create new media""" 14 | view = self.navigate_to(self, 'New') 15 | view.fill(values) 16 | view.submit.click() 17 | view.flash.assert_no_error() 18 | view.flash.dismiss() 19 | 20 | def search(self, value): 21 | """Search for specific media""" 22 | view = self.navigate_to(self, 'All') 23 | return view.search(value) 24 | 25 | def read(self, entity_name, widget_names=None): 26 | """Read values for existing media""" 27 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 28 | return view.read(widget_names=widget_names) 29 | 30 | def update(self, entity_name, values): 31 | """Update media values""" 32 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 33 | view.fill(values) 34 | view.submit.click() 35 | view.flash.assert_no_error() 36 | view.flash.dismiss() 37 | 38 | def delete(self, entity_name): 39 | """Delete media""" 40 | view = self.navigate_to(self, 'All') 41 | view.search(entity_name) 42 | view.table.row(name=entity_name)['Actions'].widget.click(handle_alert=True) 43 | 44 | 45 | @navigator.register(MediaEntity, 'All') 46 | class ShowAllMedium(NavigateStep): 47 | """Navigate to All Medium screen.""" 48 | 49 | VIEW = MediumView 50 | 51 | @retry_navigation 52 | def step(self, *args, **kwargs): 53 | self.view.menu.select('Hosts', 'Provisioning Setup', 'Installation Media') 54 | 55 | 56 | @navigator.register(MediaEntity, 'New') 57 | class AddNewMedia(NavigateStep): 58 | """Navigate to Create new Media screen.""" 59 | 60 | VIEW = MediaCreateView 61 | 62 | prerequisite = NavigateToSibling('All') 63 | 64 | def step(self, *args, **kwargs): 65 | self.parent.new.click() 66 | 67 | 68 | @navigator.register(MediaEntity, 'Edit') 69 | class EditMedia(NavigateStep): 70 | """Navigate to Edit Media screen. 71 | 72 | Args: 73 | entity_name: name of media 74 | """ 75 | 76 | VIEW = MediaEditView 77 | 78 | def prerequisite(self, *args, **kwargs): 79 | return self.navigate_to(self.obj, 'All') 80 | 81 | def step(self, *args, **kwargs): 82 | entity_name = kwargs.get('entity_name') 83 | self.parent.search(entity_name) 84 | self.parent.table.row(name=entity_name)['Name'].widget.click() 85 | -------------------------------------------------------------------------------- /airgun/entities/subnet.py: -------------------------------------------------------------------------------- 1 | from navmazing import NavigateToSibling 2 | 3 | from airgun.entities.base import BaseEntity 4 | from airgun.navigation import NavigateStep, navigator 5 | from airgun.utils import retry_navigation 6 | from airgun.views.subnet import SubnetCreateView, SubnetEditView, SubnetsView 7 | 8 | 9 | class SubnetEntity(BaseEntity): 10 | endpoint_path = '/subnets' 11 | 12 | def create(self, values): 13 | """Create new subnet""" 14 | view = self.navigate_to(self, 'New') 15 | view.fill(values) 16 | view.submit.click() 17 | view.flash.assert_no_error() 18 | view.flash.dismiss() 19 | 20 | def search(self, value): 21 | """Search for specific subnet""" 22 | view = self.navigate_to(self, 'All') 23 | return view.search(value) 24 | 25 | def read(self, entity_name, widget_names=None): 26 | """Read values for existing subnet""" 27 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 28 | return view.read(widget_names=widget_names) 29 | 30 | def update(self, entity_name, values): 31 | """Update subnet values""" 32 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 33 | view.fill(values) 34 | view.submit.click() 35 | view.flash.assert_no_error() 36 | view.flash.dismiss() 37 | 38 | def delete(self, entity_name): 39 | """Delete subnet""" 40 | view = self.navigate_to(self, 'All') 41 | view.search(entity_name) 42 | view.table.row(name=entity_name)['Actions'].widget.click(handle_alert=True) 43 | 44 | 45 | @navigator.register(SubnetEntity, 'All') 46 | class ShowAllSubnets(NavigateStep): 47 | """Navigate to All Subnets screen.""" 48 | 49 | VIEW = SubnetsView 50 | 51 | @retry_navigation 52 | def step(self, *args, **kwargs): 53 | self.view.menu.select('Infrastructure', 'Subnets') 54 | 55 | 56 | @navigator.register(SubnetEntity, 'New') 57 | class AddNewSubnet(NavigateStep): 58 | """Navigate to Create new Subnet screen.""" 59 | 60 | VIEW = SubnetCreateView 61 | 62 | prerequisite = NavigateToSibling('All') 63 | 64 | def step(self, *args, **kwargs): 65 | self.parent.new.click() 66 | 67 | 68 | @navigator.register(SubnetEntity, 'Edit') 69 | class EditSubnet(NavigateStep): 70 | """Navigate to Edit Subnet screen. 71 | 72 | Args: 73 | entity_name: name of subnet 74 | """ 75 | 76 | VIEW = SubnetEditView 77 | 78 | def prerequisite(self, *args, **kwargs): 79 | return self.navigate_to(self.obj, 'All') 80 | 81 | def step(self, *args, **kwargs): 82 | entity_name = kwargs.get('entity_name') 83 | self.parent.search(entity_name) 84 | self.parent.table.row(name=entity_name)['Name'].widget.click() 85 | -------------------------------------------------------------------------------- /airgun/entities/puppet_class.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.navigation import NavigateStep, navigator 3 | from airgun.utils import retry_navigation 4 | from airgun.views.puppet_class import PuppetClassDetailsView, PuppetClassesView 5 | 6 | 7 | class PuppetClassEntity(BaseEntity): 8 | endpoint_path = '/foreman_puppet/puppetclasses' 9 | 10 | def search(self, value): 11 | """Search for puppet class entity""" 12 | view = self.navigate_to(self, 'All') 13 | return view.search(value) 14 | 15 | def read(self, entity_name, widget_names=None): 16 | """Read puppet class entity values""" 17 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 18 | return view.read(widget_names=widget_names) 19 | 20 | def read_smart_class_parameter(self, entity_name, parameter_name): 21 | """Read smart class parameter values for specific puppet class""" 22 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 23 | view.smart_class_parameter.filter.fill(parameter_name) 24 | view.smart_class_parameter.parameter_list.fill(parameter_name.replace('_', ' ')) 25 | return view.smart_class_parameter.parameter.read() 26 | 27 | def update(self, entity_name, values): 28 | """Update puppet class values""" 29 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 30 | view.fill(values) 31 | view.submit.click() 32 | view.flash.assert_no_error() 33 | view.flash.dismiss() 34 | 35 | def delete(self, entity_name): 36 | """Delete puppet class entity""" 37 | view = self.navigate_to(self, 'All') 38 | view.search(entity_name) 39 | view.table.row(name=entity_name)['Actions'].widget.click(handle_alert=True) 40 | view.flash.assert_no_error() 41 | view.flash.dismiss() 42 | 43 | 44 | @navigator.register(PuppetClassEntity, 'All') 45 | class ShowAllPuppetClasses(NavigateStep): 46 | """Navigate to All Puppet Classes screen.""" 47 | 48 | VIEW = PuppetClassesView 49 | 50 | @retry_navigation 51 | def step(self, *args, **kwargs): 52 | self.view.menu.select('Configure', 'Puppet ENC', 'Classes') 53 | 54 | 55 | @navigator.register(PuppetClassEntity, 'Edit') 56 | class EditPuppetClass(NavigateStep): 57 | """Navigate to Edit Puppet Class screen. 58 | 59 | Args: 60 | entity_name: name of puppet class 61 | """ 62 | 63 | VIEW = PuppetClassDetailsView 64 | 65 | def prerequisite(self, *args, **kwargs): 66 | return self.navigate_to(self.obj, 'All') 67 | 68 | def step(self, *args, **kwargs): 69 | entity_name = kwargs.get('entity_name') 70 | self.parent.search(entity_name) 71 | self.parent.table.row(name=entity_name)['Name'].widget.click() 72 | -------------------------------------------------------------------------------- /airgun/views/oscaptailoringfile.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import FileInput, Table, Text, TextInput, View 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import BaseLoggedInView, SatTab, SearchableViewMixin 5 | from airgun.widgets import ActionsDropdown, MultiSelect 6 | 7 | 8 | class SCAPTailoringFilesView(BaseLoggedInView, SearchableViewMixin): 9 | title = Text("//h1[normalize-space(.)='Tailoring Files']") 10 | new = Text("//a[contains(@href, 'tailoring_files/new')]") 11 | table = Table( 12 | './/table', 13 | column_widgets={ 14 | 'Name': Text("./a[contains(@href, '/compliance/tailoring_files')]"), 15 | 'Actions': ActionsDropdown("./div[contains(@class, 'btn-group')]"), 16 | }, 17 | ) 18 | 19 | @property 20 | def is_displayed(self): 21 | return self.browser.wait_for_element(self.title, exception=False) is not None 22 | 23 | 24 | class SCAPTailoringFileCreateView(BaseLoggedInView): 25 | breadcrumb = BreadCrumb() 26 | submit = Text('//input[@name="commit"]') 27 | cancel = Text('//a[normalize-space(.)="Cancel"]') 28 | 29 | @property 30 | def is_displayed(self): 31 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 32 | return ( 33 | breadcrumb_loaded 34 | and self.breadcrumb.locations[0] == 'Tailoring files' 35 | and self.breadcrumb.read() == 'Upload new Tailoring File' 36 | ) 37 | 38 | @View.nested 39 | class file_upload(SatTab): 40 | TAB_NAME = 'File Upload' 41 | name = TextInput(id='tailoring_file_name') 42 | scap_file = FileInput(id='tailoring_file_scap_file') 43 | 44 | @View.nested 45 | class locations(SatTab): 46 | resources = MultiSelect(id='ms-tailoring_file_location_ids') 47 | 48 | @View.nested 49 | class organizations(SatTab): 50 | resources = MultiSelect(id='ms-tailoring_file_organization_ids') 51 | 52 | 53 | class SCAPTailoringFileEditView(SCAPTailoringFileCreateView): 54 | scap_file_name = Text('//label[contains(., "Scap File")]/following-sibling::div/b') 55 | 56 | @View.nested 57 | class file_upload(SatTab): 58 | TAB_NAME = 'File Upload' 59 | name = TextInput(id='tailoring_file_name') 60 | uploaded_scap_file = Text(locator="//label[@for='scap_file']/following-sibling::div/b") 61 | scap_file = FileInput(id='tailoring_file_scap_file') 62 | 63 | @property 64 | def is_displayed(self): 65 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 66 | return ( 67 | breadcrumb_loaded 68 | and self.breadcrumb.locations[0] == 'Tailoring files' 69 | and self.breadcrumb.read() != 'Upload new Tailoring File' 70 | ) 71 | -------------------------------------------------------------------------------- /airgun/entities/modulestream.py: -------------------------------------------------------------------------------- 1 | from wait_for import wait_for 2 | 3 | from airgun.entities.base import BaseEntity 4 | from airgun.navigation import NavigateStep, navigator 5 | from airgun.utils import retry_navigation 6 | from airgun.views.modulestream import ModuleStreamsDetailsView, ModuleStreamView 7 | 8 | 9 | class ModuleStreamEntity(BaseEntity): 10 | endpoint_path = '/module_streams' 11 | 12 | def search(self, query): 13 | """Search for module stream 14 | 15 | :param str query: search query to type into search field. E.g. 16 | ``name = "ant"``. 17 | """ 18 | view = self.navigate_to(self, 'All') 19 | return view.search(query) 20 | 21 | def read(self, entity_name, stream_version, widget_names=None): 22 | """Read module streams values from Module Stream Details page 23 | 24 | :param str entity_name: the module stream name to read. 25 | :param str stream_version: stream version of module. 26 | """ 27 | view = self.navigate_to( 28 | self, 'Details', entity_name=entity_name, stream_version=stream_version 29 | ) 30 | return view.read(widget_names=widget_names) 31 | 32 | 33 | @navigator.register(ModuleStreamEntity, 'All') 34 | class ShowAllModuleStreams(NavigateStep): 35 | """navigate to Module Streams Page""" 36 | 37 | VIEW = ModuleStreamView 38 | 39 | @retry_navigation 40 | def step(self, *args, **kwargs): 41 | self.view.menu.select('Content', 'Content Types', 'Module Streams') 42 | 43 | 44 | @navigator.register(ModuleStreamEntity, 'Details') 45 | class ShowModuleStreamsDetails(NavigateStep): 46 | """Navigate to Module Stream Details page by clicking on 47 | necessary module name in the table 48 | 49 | Args: 50 | entity_name: The module name. 51 | module_version: The version of module stream. 52 | """ 53 | 54 | VIEW = ModuleStreamsDetailsView 55 | 56 | def prerequisite(self, *args, **kwargs): 57 | return self.navigate_to(self.obj, 'All') 58 | 59 | def step(self, *args, **kwargs): 60 | entity_name = kwargs.get('entity_name') 61 | stream_version = kwargs.get('stream_version') 62 | self.parent.search(f'name = {entity_name} and stream = {stream_version}') 63 | self.parent.table.row(name=entity_name, stream=stream_version)['Name'].widget.click() 64 | 65 | def post_navigate(self, _tries, *args, **kwargs): 66 | wait_for( 67 | lambda: self.am_i_here(*args, **kwargs), 68 | timeout=30, 69 | delay=1, 70 | handle_exception=True, 71 | logger=self.view.logger, 72 | ) 73 | 74 | def am_i_here(self, *args, **kwargs): 75 | entity_name = kwargs.get('entity_name') 76 | return self.view.is_displayed and self.view.breadcrumb.locations[1].startswith(entity_name) 77 | -------------------------------------------------------------------------------- /airgun/entities/task.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from airgun.entities.base import BaseEntity 4 | from airgun.navigation import NavigateStep, navigator 5 | from airgun.utils import retry_navigation 6 | from airgun.views.task import TaskDetailsView, TasksView 7 | 8 | 9 | class TaskEntity(BaseEntity): 10 | endpoint_path = '/foreman_tasks/tasks' 11 | 12 | def search(self, value): 13 | """Search for specific task""" 14 | view = self.navigate_to(self, 'All') 15 | return view.search(value) 16 | 17 | def read_all(self, widget_names=None): 18 | """Read all tasks widgets values from the title page. 19 | Or read specific widgets by adding 'widget_names' parameter 20 | """ 21 | view = self.navigate_to(self, 'All') 22 | return view.read(widget_names=widget_names) 23 | 24 | def read(self, entity_name, widget_names=None): 25 | """Read specific task values from details page""" 26 | view = self.navigate_to(self, 'Details', entity_name=entity_name) 27 | time.sleep(3) 28 | return view.read(widget_names=widget_names) 29 | 30 | def set_chart_filter(self, chart_name, index=None): 31 | """Remove filter from searchbox and set filter from specific chart 32 | 33 | :param index: index in 'StoppedChart' table, 34 | dict with 'row' number and 'focus' as column name 35 | """ 36 | view = self.navigate_to(self, 'All') 37 | chart = getattr(view, chart_name) 38 | view.searchbox.clear() 39 | if chart_name == 'StoppedChart' and index: 40 | chart.table[index['row']][index['focus']].click() 41 | else: 42 | chart.name.click() 43 | 44 | def total_items(self): 45 | """Get total items displayed in the table""" 46 | view = self.navigate_to(self, 'All') 47 | return view.pagination.total_items 48 | 49 | 50 | @navigator.register(TaskEntity, 'All') 51 | class ShowAllTasks(NavigateStep): 52 | """Navigate to All Tasks page""" 53 | 54 | VIEW = TasksView 55 | 56 | @retry_navigation 57 | def step(self, *args, **kwargs): 58 | product = ( 59 | 'Foreman' if self.view.product.is_displayed else 'Satellite' 60 | ) # make it work on nightly 61 | self.view.menu.select('Monitor', f'{product} Tasks', 'Tasks') 62 | 63 | 64 | @navigator.register(TaskEntity, 'Details') 65 | class TaskDetails(NavigateStep): 66 | """Navigate to Task Details screen. 67 | 68 | Args: 69 | entity_name: name of the task 70 | """ 71 | 72 | VIEW = TaskDetailsView 73 | 74 | def prerequisite(self, *args, **kwargs): 75 | return self.navigate_to(self.obj, 'All') 76 | 77 | def step(self, *args, **kwargs): 78 | entity_name = kwargs.get('entity_name') 79 | self.parent.search(entity_name) 80 | self.parent.table.row(action=entity_name)['Action'].widget.click() 81 | -------------------------------------------------------------------------------- /airgun/views/discoveryrule.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Checkbox, Table, Text, TextInput, View 2 | from widgetastic_patternfly import BreadCrumb 3 | from widgetastic_patternfly5 import Button as PF5Button 4 | 5 | from airgun.views.common import BaseLoggedInView, SatTab, SearchableViewMixinPF4 6 | from airgun.widgets import ( 7 | ActionsDropdown, 8 | AutoCompleteTextInput, 9 | FilteredDropdown, 10 | MultiSelect, 11 | ) 12 | 13 | 14 | class DiscoveryRulesView(BaseLoggedInView, SearchableViewMixinPF4): 15 | title = Text("//h1[normalize-space(.)='Discovery Rules']") 16 | page_info = Text("//foreman-react-component[contains(@name, 'DiscoveryRules')]/div/div") 17 | new = Text("//a[contains(@href, '/discovery_rules/new')]") 18 | new_on_blank_page = PF5Button('Create Rule') 19 | table = Table( 20 | './/table', 21 | column_widgets={ 22 | 'Name': Text('.//a'), 23 | 'Actions': ActionsDropdown("./div[contains(@class, 'btn-group')]"), 24 | }, 25 | ) 26 | 27 | @property 28 | def is_displayed(self): 29 | return self.browser.wait_for_element(self.title, exception=False) is not None 30 | 31 | 32 | class DiscoveryRuleCreateView(BaseLoggedInView): 33 | submit = Text('//input[@name="commit"]') 34 | cancel = Text('//a[normalize-space(.)="Cancel"]') 35 | breadcrumb = BreadCrumb() 36 | 37 | @property 38 | def is_displayed(self): 39 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 40 | return ( 41 | breadcrumb_loaded 42 | and self.breadcrumb.locations[0] == 'Discovery rules' 43 | and self.breadcrumb.read() == 'New Discovery Rule' 44 | ) 45 | 46 | @View.nested 47 | class primary(SatTab): 48 | name = TextInput(id='discovery_rule_name') 49 | search = AutoCompleteTextInput(name='discovery_rule[search]') 50 | host_group = FilteredDropdown(id='discovery_rule_hostgroup_id') 51 | hostname = TextInput(id='discovery_rule_hostname') 52 | hosts_limit = TextInput(id='discovery_rule_max_count') 53 | priority = TextInput(id='discovery_rule_priority') 54 | enabled = Checkbox(id='discovery_rule_enabled') 55 | 56 | @View.nested 57 | class locations(SatTab): 58 | resources = MultiSelect(id='ms-discovery_rule_location_ids') 59 | 60 | @View.nested 61 | class organizations(SatTab): 62 | resources = MultiSelect(id='ms-discovery_rule_organization_ids') 63 | 64 | 65 | class DiscoveryRuleEditView(DiscoveryRuleCreateView): 66 | @property 67 | def is_displayed(self): 68 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 69 | return ( 70 | breadcrumb_loaded 71 | and self.breadcrumb.locations[0] == 'Discovery rules' 72 | and self.breadcrumb.read().startswith('Edit ') 73 | ) 74 | -------------------------------------------------------------------------------- /airgun/views/syncplan.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Select, Table, Text, TextInput, View 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import ( 5 | AddRemoveResourcesView, 6 | BaseLoggedInView, 7 | SatTab, 8 | SearchableViewMixin, 9 | ) 10 | from airgun.widgets import ( 11 | ActionsDropdown, 12 | ConfirmationDialog, 13 | DateTime, 14 | EditableDateTime, 15 | EditableEntry, 16 | EditableEntryCheckbox, 17 | EditableEntrySelect, 18 | ReadOnlyEntry, 19 | ) 20 | 21 | 22 | class SyncPlansView(BaseLoggedInView, SearchableViewMixin): 23 | title = Text("//h2[contains(., 'Sync Plans')]") 24 | new = Text("//button[contains(@href, '/sync_plans/new')]") 25 | table = Table('.//table', column_widgets={'Name': Text('./a')}) 26 | 27 | @property 28 | def is_displayed(self): 29 | return self.browser.wait_for_element(self.title, exception=False) is not None 30 | 31 | 32 | class SyncPlanCreateView(BaseLoggedInView): 33 | breadcrumb = BreadCrumb() 34 | name = TextInput(id='name') 35 | description = TextInput(id='description') 36 | interval = Select(id='interval') 37 | cron_expression = TextInput(id='cron_expression') 38 | date_time = DateTime() 39 | submit = Text("//button[contains(@ng-click, 'handleSave')]") 40 | 41 | @property 42 | def is_displayed(self): 43 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 44 | return ( 45 | breadcrumb_loaded 46 | and self.breadcrumb.locations[0] == 'Sync Plans' 47 | and self.breadcrumb.read() == 'New Sync Plan' 48 | ) 49 | 50 | 51 | class SyncPlanEditView(BaseLoggedInView): 52 | breadcrumb = BreadCrumb() 53 | actions = ActionsDropdown("//div[contains(@class, 'btn-group')]") 54 | dialog = ConfirmationDialog() 55 | 56 | @property 57 | def is_displayed(self): 58 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 59 | return ( 60 | breadcrumb_loaded 61 | and self.breadcrumb.locations[0] == 'Sync Plans' 62 | and self.breadcrumb.read() != 'New Sync Plan' 63 | ) 64 | 65 | @View.nested 66 | class details(SatTab): 67 | name = EditableEntry(name='Name') 68 | description = EditableEntry(name='Description') 69 | date_time = EditableDateTime(name='Start Date') 70 | next_sync = ReadOnlyEntry(name='Next Sync') 71 | recurring_logic = ReadOnlyEntry(name='Recurring Logic') 72 | enabled = EditableEntryCheckbox(name='Sync Enabled') 73 | interval = EditableEntrySelect(name='Interval') 74 | cron_expression = EditableEntry(name='Cron Logic') 75 | products_count = ReadOnlyEntry(name='Products') 76 | 77 | @View.nested 78 | class products(SatTab): 79 | resources = View.nested(AddRemoveResourcesView) 80 | -------------------------------------------------------------------------------- /airgun/settings.py: -------------------------------------------------------------------------------- 1 | from configparser import ConfigParser 2 | import logging 3 | import os 4 | 5 | SETTINGS_FILE_NAME = 'settings.ini' 6 | 7 | 8 | def get_project_root(): 9 | """Return the path to the project root directory. 10 | 11 | :return: A directory path. 12 | :rtype: str 13 | """ 14 | return os.path.realpath( 15 | os.path.join( 16 | os.path.dirname(__file__), 17 | os.pardir, 18 | ) 19 | ) 20 | 21 | 22 | class AirgunSettings: 23 | def __init__(self): 24 | self.verbosity = None 25 | self.tmp_dir = None 26 | 27 | 28 | class SatelliteSettings: 29 | def __init__(self): 30 | self.hostname = None 31 | self.username = None 32 | self.password = None 33 | 34 | 35 | class SeleniumSettings: 36 | def __init__(self): 37 | self.browser = None 38 | self.screenshots_path = None 39 | self.webdriver = None 40 | self.webdriver_binary = None 41 | self.browseroptions = None 42 | 43 | 44 | class WebKaifukuSettings: 45 | def __init__(self): 46 | self.config = None 47 | 48 | 49 | class Settings: 50 | def __init__(self): 51 | self.configured = False 52 | self.airgun = AirgunSettings() 53 | self.satellite = SatelliteSettings() 54 | self.selenium = SeleniumSettings() 55 | self.webkaifuku = WebKaifukuSettings() 56 | 57 | def _configure_logging(self): 58 | logging.captureWarnings(False) 59 | logging.basicConfig( 60 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 61 | datefmt='%Y-%m-%d %H:%M:%S', 62 | level=self.airgun.verbosity, 63 | ) 64 | logging.getLogger('airgun').setLevel(self.airgun.verbosity) 65 | 66 | def _configure_thirdparty_logging(self): 67 | logging.getLogger('widgetastic_null').setLevel(self.airgun.verbosity) 68 | 69 | def configure(self, settings=None): 70 | """Parses arg `settings` or settings file if None passed and sets class 71 | attributes accordingly 72 | """ 73 | config = ConfigParser() 74 | # using str instead of optionxform not to .lower() options 75 | config.optionxform = str 76 | if settings is not None: 77 | for section in settings: 78 | config.add_section(section) 79 | for key, value in settings[section].items(): 80 | config.set(section, key, str(value)) 81 | else: 82 | settings_path = os.path.join(get_project_root(), SETTINGS_FILE_NAME) 83 | config.read(settings_path) 84 | 85 | for section in config.sections(): 86 | for key, value in config[section].items(): 87 | setattr(getattr(self, section), key, value) 88 | 89 | self._configure_logging() 90 | self._configure_thirdparty_logging() 91 | 92 | self.configured = True 93 | -------------------------------------------------------------------------------- /airgun/views/user.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Checkbox, Table, Text, TextInput, View 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import BaseLoggedInView, SatTab, SearchableViewMixinPF4 5 | from airgun.widgets import FilteredDropdown, MultiSelect 6 | 7 | 8 | class UsersView(BaseLoggedInView, SearchableViewMixinPF4): 9 | title = Text("//h1[normalize-space(.)='Users']") 10 | new = Text("//a[contains(@href, '/users/new')]") 11 | dropdown = Text("//a[@href='#' and contains(@class, 'dropdown-toggle')]") 12 | invalidate_jwt = Text('.//a[@data-method="patch"]') 13 | impersonate_user = Text('.//a[@data-method="post"]') 14 | table = Table( 15 | './/table', 16 | column_widgets={ 17 | 'Username': Text('./a'), 18 | 'Last login time': Text('./span'), 19 | 'Actions': Text('.//a[@data-method="delete"]'), 20 | }, 21 | ) 22 | 23 | @property 24 | def is_displayed(self): 25 | return self.browser.wait_for_element(self.title, exception=False) is not None 26 | 27 | 28 | class UserDetailsView(BaseLoggedInView): 29 | breadcrumb = BreadCrumb() 30 | submit = Text('//input[@name="commit"]') 31 | 32 | @property 33 | def is_displayed(self): 34 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 35 | return ( 36 | breadcrumb_loaded 37 | and self.breadcrumb.locations[0] == 'Users' 38 | and self.breadcrumb.read().startswith('Edit ') 39 | ) 40 | 41 | @View.nested 42 | class user(SatTab): 43 | login = TextInput(id='user_login') 44 | firstname = TextInput(id='user_firstname') 45 | lastname = TextInput(id='user_lastname') 46 | mail = TextInput(id='user_mail') 47 | description = TextInput(id='user_description') 48 | language = FilteredDropdown(id='user_locale') 49 | timezone = FilteredDropdown(id='user_timezone') 50 | auth = FilteredDropdown(id='user_auth_source') 51 | password = TextInput(id='user_password') 52 | confirm = TextInput(id='password_confirmation') 53 | 54 | @View.nested 55 | class locations(SatTab): 56 | resources = MultiSelect(id='ms-user_location_ids') 57 | 58 | @View.nested 59 | class organizations(SatTab): 60 | resources = MultiSelect(id='ms-user_organization_ids') 61 | 62 | @View.nested 63 | class roles(SatTab): 64 | admin = Checkbox(id='user_admin') 65 | resources = MultiSelect(id='ms-user_role_ids') 66 | 67 | 68 | class UserCreateView(UserDetailsView): 69 | @property 70 | def is_displayed(self): 71 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 72 | return ( 73 | breadcrumb_loaded 74 | and self.breadcrumb.locations[0] == 'Users' 75 | and self.breadcrumb.read() == 'Create User' 76 | ) 77 | -------------------------------------------------------------------------------- /airgun/entities/oscapreport.py: -------------------------------------------------------------------------------- 1 | from wait_for import wait_for 2 | 3 | from airgun.entities.base import BaseEntity 4 | from airgun.navigation import NavigateStep, navigator 5 | from airgun.utils import retry_navigation 6 | from airgun.views.oscapreport import ( 7 | RemediateModal, 8 | SCAPReportDetailsView, 9 | SCAPReportView, 10 | ) 11 | 12 | 13 | class OSCAPReportEntity(BaseEntity): 14 | endpoint_path = '/compliance/arf_reports' 15 | 16 | def search(self, search_string): 17 | """Search for SCAP Report 18 | 19 | :param search_string: how to find the SCAP Report 20 | :return: result of the SCAP Report search 21 | """ 22 | view = self.navigate_to(self, 'All') 23 | return view.search(search_string) 24 | 25 | def details(self, search_string, widget_names=None, limit=None): 26 | """Read the content from corresponding SCAP Report dashboard, 27 | clicking on the link in Reported At column of 28 | SCAP Report list 29 | 30 | :param search_string: 31 | :param limit: how many rules results to fetch at most 32 | :return: list of dictionaries with values from SCAP Report Details View 33 | """ 34 | view = self.navigate_to(self, 'Details', search_string=search_string) 35 | return view.read(widget_names=widget_names, limit=limit) 36 | 37 | def remediate(self, search_string, resource): 38 | """Remediate the failed rule using automatic remediation through Ansible 39 | 40 | :param search_string: 41 | """ 42 | view = self.navigate_to(self, 'Details', search_string=search_string) 43 | view.table.row(resource=resource).actions.fill('Remediation') 44 | view = RemediateModal(self.browser) 45 | view.wait_displayed() 46 | self.browser.plugin.ensure_page_safe() 47 | wait_for(lambda: view.title.is_displayed, timeout=10, delay=1) 48 | view.fill({'select_remediation_method.snippet': 'Ansible'}) 49 | view.select_capsule.run.click() 50 | 51 | 52 | @navigator.register(OSCAPReportEntity, 'All') 53 | class ShowAllSCAPReports(NavigateStep): 54 | """Navigate to Compliance Reports screen.""" 55 | 56 | VIEW = SCAPReportView 57 | 58 | @retry_navigation 59 | def step(self, *args, **kwargs): 60 | self.view.menu.select('Hosts', 'Compliance', 'Reports') 61 | 62 | 63 | @navigator.register(OSCAPReportEntity, 'Details') 64 | class DetailsSCAPReport(NavigateStep): 65 | """To get data from ARF report view 66 | 67 | Args: 68 | search_string: what to fill to find the SCAP report 69 | """ 70 | 71 | VIEW = SCAPReportDetailsView 72 | 73 | def prerequisite(self, *args, **kwargs): 74 | return self.navigate_to(self.obj, 'All') 75 | 76 | def step(self, *args, **kwargs): 77 | search_string = kwargs.get('search_string') 78 | self.parent.search(search_string) 79 | self.parent.table.row()['Reported At'].widget.click() 80 | -------------------------------------------------------------------------------- /.github/workflows/auto_cherry_pick_merge.yaml: -------------------------------------------------------------------------------- 1 | name: automerge auto-cherry-picked pr's 2 | on: 3 | pull_request_target: 4 | types: 5 | - labeled 6 | - unlabeled 7 | - edited 8 | - ready_for_review 9 | branches-ignore: 10 | - master 11 | check_suite: 12 | types: 13 | - completed 14 | branches-ignore: 15 | - master 16 | 17 | 18 | jobs: 19 | automerge: 20 | name: Automerge auto-cherry-picked pr 21 | if: contains(github.event.pull_request.labels.*.name, 'AutoMerge_Cherry_Picked') && contains(github.event.pull_request.labels.*.name, 'Auto_Cherry_Picked') 22 | runs-on: ubuntu-latest 23 | steps: 24 | - id: find-prt-comment 25 | name: Find the prt comment 26 | uses: peter-evans/find-comment@v4 27 | with: 28 | issue-number: ${{ github.event.number }} 29 | body-includes: "trigger: test-robottelo" 30 | direction: last 31 | 32 | - name: Fail automerge if PRT was not initiated 33 | if: steps.find-prt-comment.outputs.comment-body == '' 34 | run: | 35 | echo "::error PRT comment not added the PR" 36 | 37 | - name: Wait for PRT checks to get initiated 38 | run: | 39 | echo "Waiting for ~ 10 mins, PRT to be initiated." && sleep 600 40 | 41 | - name: Wait for other status checks to Pass 42 | id: waitforstatuschecks 43 | uses: lewagon/wait-on-check-action@v1.4.1 44 | with: 45 | ref: ${{ github.head_ref }} 46 | repo-token: ${{ secrets.CHERRYPICK_PAT }} 47 | wait-interval: 60 48 | running-workflow-name: 'Automerge auto-cherry-picked pr' 49 | allowed-conclusions: success,skipped 50 | 51 | - name: Fetch the PRT status 52 | id: outcome 53 | uses: omkarkhatavkar/wait-for-status-checks@main 54 | with: 55 | ref: ${{ github.event.pull_request.head.sha }} 56 | context: 'Airgun-Runner' 57 | wait-interval: 60 58 | count: 100 59 | 60 | - name: Check the PRT status 61 | run: | 62 | if [ ${{ steps.outcome.outputs.result }} == 'success' ]; then 63 | echo "Status check passed!" 64 | else 65 | echo "Status check failed!" 66 | fi 67 | 68 | - id: automerge 69 | name: Auto merge of cherry-picked PRs. 70 | uses: "pascalgn/automerge-action@v0.16.4" 71 | env: 72 | GITHUB_TOKEN: "${{ secrets.CHERRYPICK_PAT }}" 73 | MERGE_LABELS: "AutoMerge_Cherry_Picked, Auto_Cherry_Picked" 74 | MERGE_METHOD: "squash" 75 | MERGE_RETRIES: 5 76 | MERGE_RETRY_SLEEP: 900000 77 | 78 | - name: Auto Merge Status 79 | run: | 80 | if [ "${{ steps.automerge.outputs.mergeResult }}" == 'merged' ]; then 81 | echo "Pull request ${{ steps.automerge.outputs.pullRequestNumber }} is Auto Merged !" 82 | else 83 | echo "::error Auto Merge for Pull request failed !" 84 | exit 1 85 | fi 86 | -------------------------------------------------------------------------------- /airgun/entities/configgroup.py: -------------------------------------------------------------------------------- 1 | from navmazing import NavigateToSibling 2 | 3 | from airgun.entities.base import BaseEntity 4 | from airgun.navigation import NavigateStep, navigator 5 | from airgun.utils import retry_navigation 6 | from airgun.views.configgroup import ( 7 | ConfigGroupCreateView, 8 | ConfigGroupEditView, 9 | ConfigGroupsView, 10 | ) 11 | 12 | 13 | class ConfigGroupEntity(BaseEntity): 14 | endpoint_path = '/foreman_puppet/config_groups' 15 | 16 | def create(self, values): 17 | """Create new config group""" 18 | view = self.navigate_to(self, 'New') 19 | view.fill(values) 20 | view.submit.click() 21 | view.flash.assert_no_error() 22 | view.flash.dismiss() 23 | 24 | def search(self, value): 25 | """Search for existing config group""" 26 | view = self.navigate_to(self, 'All') 27 | return view.search(value) 28 | 29 | def read(self, entity_name, widget_names=None): 30 | """Read existing config group""" 31 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 32 | return view.read(widget_names=widget_names) 33 | 34 | def update(self, entity_name, values): 35 | """Update config group""" 36 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 37 | view.fill(values) 38 | view.submit.click() 39 | view.flash.assert_no_error() 40 | view.flash.dismiss() 41 | 42 | def delete(self, entity_name): 43 | """Delete config group""" 44 | view = self.navigate_to(self, 'All') 45 | view.searchbox.search(entity_name) 46 | view.table.row(name=entity_name)['Actions'].widget.click(handle_alert=True) 47 | view.flash.assert_no_error() 48 | view.flash.dismiss() 49 | 50 | 51 | @navigator.register(ConfigGroupEntity, 'All') 52 | class ShowAllConfigGroups(NavigateStep): 53 | """Navigate to All Config Groups screen.""" 54 | 55 | VIEW = ConfigGroupsView 56 | 57 | @retry_navigation 58 | def step(self, *args, **kwargs): 59 | self.view.menu.select('Configure', 'Puppet ENC', 'Config Groups') 60 | 61 | 62 | @navigator.register(ConfigGroupEntity, 'New') 63 | class AddNewConfigGroup(NavigateStep): 64 | """Navigate to Create new Config Group screen.""" 65 | 66 | VIEW = ConfigGroupCreateView 67 | 68 | prerequisite = NavigateToSibling('All') 69 | 70 | def step(self, *args, **kwargs): 71 | self.parent.new.click() 72 | 73 | 74 | @navigator.register(ConfigGroupEntity, 'Edit') 75 | class EditConfigGroup(NavigateStep): 76 | """Navigate to Edit Config Group screen. 77 | 78 | Args: 79 | entity_name: name of config group 80 | """ 81 | 82 | VIEW = ConfigGroupEditView 83 | 84 | def prerequisite(self, *args, **kwargs): 85 | return self.navigate_to(self.obj, 'All') 86 | 87 | def step(self, *args, **kwargs): 88 | entity_name = kwargs.get('entity_name') 89 | self.parent.search(entity_name) 90 | self.parent.table.row(name=entity_name)['Name'].widget.click() 91 | -------------------------------------------------------------------------------- /airgun/views/sync_templates.py: -------------------------------------------------------------------------------- 1 | from wait_for import wait_for 2 | from widgetastic.widget import ( 3 | Checkbox, 4 | ConditionalSwitchableView, 5 | Select, 6 | Text, 7 | TextInput, 8 | View, 9 | ) 10 | from widgetastic_patternfly import BreadCrumb 11 | 12 | from airgun.views.common import BaseLoggedInView 13 | from airgun.widgets import RadioGroup 14 | 15 | 16 | class SyncTemplatesView(BaseLoggedInView): 17 | breadcrumb = BreadCrumb() 18 | title = Text("//h2[contains(., 'Import or Export Templates')]") 19 | sync_type = RadioGroup("//div[label[contains(., 'Action type')]]") 20 | submit = Text(".//button[contains(.,'Submit')]") 21 | 22 | template = ConditionalSwitchableView(reference='sync_type') 23 | 24 | @property 25 | def is_displayed(self): 26 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 27 | return ( 28 | breadcrumb_loaded 29 | and self.browser.wait_for_element(self.title, exception=False) is not None 30 | ) 31 | 32 | def before_fill(self, values): 33 | """Wait for Sync Type Radio Button to be displayed""" 34 | wait_for(lambda: self.sync_type.is_displayed, timeout=10, delay=1, logger=self.logger) 35 | 36 | @template.register('Import') 37 | class ImportTemplates(View): 38 | associate = Select(name='import.associate') 39 | branch = TextInput(name='import.branch') 40 | dirname = TextInput(name='import.dirname') 41 | filter = TextInput(name='import.filter') 42 | force_import = Checkbox(name='import.force') 43 | lock = Select(name='import.lock') 44 | negate = Checkbox(name='import.negate') 45 | prefix = TextInput(name='import.prefix') 46 | repo = TextInput(name='import.repo') 47 | http_proxy_policy = Select(name='import.http_proxy_policy') 48 | http_proxy_id = Select(name='import.http_proxy_id') 49 | 50 | def fill(self, items): 51 | if 'http_proxy_id' in items: 52 | self.http_proxy_policy.fill(items['http_proxy_policy']) 53 | super().fill(items) 54 | 55 | @template.register('Export') 56 | class ExportTemplates(View): 57 | branch = TextInput(name='export.branch') 58 | dirname = TextInput(name='export.dirname') 59 | filter = TextInput(name='export.filter') 60 | metadata_export_mode = Select(name='export.metadata_export_mode') 61 | negate = Checkbox(name='export.negate') 62 | repo = TextInput(name='export.repo') 63 | http_proxy_policy = Select(name='export.http_proxy_policy') 64 | http_proxy_id = Select(name='export.http_proxy_id') 65 | 66 | 67 | class TemplatesReportView(BaseLoggedInView): 68 | title = Text('//h1') 69 | REPORTS = "//div[contains(@class, 'list-group-item')]" 70 | 71 | @property 72 | def is_displayed(self): 73 | return all( 74 | [ 75 | self.browser.wait_for_element(self.title, exception=False), 76 | self.browser.elements(self.REPORTS), 77 | ] 78 | ) 79 | -------------------------------------------------------------------------------- /airgun/entities/architecture.py: -------------------------------------------------------------------------------- 1 | from navmazing import NavigateToSibling 2 | 3 | from airgun.entities.base import BaseEntity 4 | from airgun.navigation import NavigateStep, navigator 5 | from airgun.utils import retry_navigation 6 | from airgun.views.architecture import ( 7 | ArchitectureCreateView, 8 | ArchitectureDetailsView, 9 | ArchitecturesView, 10 | ) 11 | 12 | 13 | class ArchitectureEntity(BaseEntity): 14 | endpoint_path = '/architectures' 15 | 16 | def create(self, values): 17 | """Create new architecture entity""" 18 | view = self.navigate_to(self, 'New') 19 | view.fill(values) 20 | view.submit.click() 21 | view.flash.assert_no_error() 22 | view.flash.dismiss() 23 | 24 | def search(self, value): 25 | """Search for architecture entity""" 26 | view = self.navigate_to(self, 'All') 27 | return view.search(value) 28 | 29 | def read(self, entity_name, widget_names=None): 30 | """Read all values for created architecture entity""" 31 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 32 | return view.read(widget_names=widget_names) 33 | 34 | def update(self, entity_name, values): 35 | """Update necessary values for architecture""" 36 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 37 | view.fill(values) 38 | view.submit.click() 39 | view.flash.assert_no_error() 40 | view.flash.dismiss() 41 | 42 | def delete(self, entity_name): 43 | """Remove existing architecture entity""" 44 | view = self.navigate_to(self, 'All') 45 | view.searchbox.search(entity_name) 46 | view.table.row(name=entity_name)['Actions'].widget.click(handle_alert=True) 47 | view.flash.assert_no_error() 48 | view.flash.dismiss() 49 | 50 | 51 | @navigator.register(ArchitectureEntity, 'All') 52 | class ShowAllArchitectures(NavigateStep): 53 | """Navigate to All Architectures page""" 54 | 55 | VIEW = ArchitecturesView 56 | 57 | @retry_navigation 58 | def step(self, *args, **kwargs): 59 | self.view.menu.select('Hosts', 'Provisioning Setup', 'Architectures') 60 | 61 | 62 | @navigator.register(ArchitectureEntity, 'New') 63 | class AddNewArchitecture(NavigateStep): 64 | """Navigate to Create Architecture page""" 65 | 66 | VIEW = ArchitectureCreateView 67 | 68 | prerequisite = NavigateToSibling('All') 69 | 70 | def step(self, *args, **kwargs): 71 | self.parent.new.click() 72 | 73 | 74 | @navigator.register(ArchitectureEntity, 'Edit') 75 | class EditArchitecture(NavigateStep): 76 | """Navigate to Edit Architecture page 77 | 78 | Args: 79 | entity_name: name of the architecture 80 | """ 81 | 82 | VIEW = ArchitectureDetailsView 83 | 84 | def prerequisite(self, *args, **kwargs): 85 | return self.navigate_to(self.obj, 'All') 86 | 87 | def step(self, *args, **kwargs): 88 | entity_name = kwargs.get('entity_name') 89 | self.parent.search(entity_name) 90 | self.parent.table.row(name=entity_name)['Name'].widget.click() 91 | -------------------------------------------------------------------------------- /airgun/entities/ansible_role.py: -------------------------------------------------------------------------------- 1 | from navmazing import NavigateToSibling 2 | 3 | from airgun.entities.base import BaseEntity 4 | from airgun.navigation import NavigateStep, navigator 5 | from airgun.views.ansible_role import AnsibleRolesImportView, AnsibleRolesView 6 | 7 | 8 | class AnsibleRolesEntity(BaseEntity): 9 | """Main Ansible roles entity""" 10 | 11 | endpoint_path = '/ansible/ansible_roles' 12 | 13 | def search(self, value): 14 | """Search for existing Ansible Role""" 15 | view = self.navigate_to(self, 'All') 16 | view.search(value) 17 | return view.table.read() 18 | 19 | def delete(self, entity_name): 20 | """Delete Ansible Role from Satellite""" 21 | # This method currently relies on searching for a specific role as 22 | # directly accessing the 'Name' column is not possible due to the 23 | # presence of the `▲` character used for sorting that column in the table 24 | # header cell. The Satellite UX team is planning to address this in a 25 | # future release, likely by wrapping the sort character in a separate 26 | # span tag from the column header text. 27 | view = self.navigate_to(self, 'All') 28 | view.search(entity_name) 29 | view.table.row()['Actions'].widget.fill('Delete') 30 | view.dialog.confirm_dialog.click() 31 | view.flash.assert_no_error() 32 | view.flash.dismiss() 33 | 34 | @property 35 | def imported_roles_count(self): 36 | """Return the number of Ansible roles currently imported into Satellite""" 37 | view = self.navigate_to(self, 'All') 38 | # Before any roles have been imported, no table or pagination widget are 39 | # present on the page 40 | # Applying wait_displayed for the page to get rendered 41 | view.wait_displayed() 42 | return int(view.total_imported_roles.read()) 43 | 44 | def import_all_roles(self): 45 | """Import all available roles and return the number of roles 46 | that were available at import time 47 | """ 48 | view = self.navigate_to(self, 'Import') 49 | available_roles_count = int(view.total_available_roles.read()) 50 | view.select_all.fill(True) 51 | view.submit.click() 52 | return available_roles_count 53 | 54 | def read_all(self): 55 | """Read all roles before importing""" 56 | view = self.navigate_to(self, 'Import') 57 | view.dropdown.click() 58 | view.max_per_pg.click() 59 | return view.roles.read() 60 | 61 | 62 | @navigator.register(AnsibleRolesEntity, 'All') 63 | class ShowAllRoles(NavigateStep): 64 | """Navigate to the Ansible Roles page""" 65 | 66 | VIEW = AnsibleRolesView 67 | 68 | def step(self, *args, **kwargs): 69 | self.view.menu.select('Configure', 'Ansible', 'Roles') 70 | 71 | 72 | @navigator.register(AnsibleRolesEntity, 'Import') 73 | class ImportAnsibleRole(NavigateStep): 74 | """Navigate to the Import Roles page""" 75 | 76 | VIEW = AnsibleRolesImportView 77 | 78 | prerequisite = NavigateToSibling('All') 79 | 80 | def step(self, *args, **kwargs): 81 | self.parent.import_button.click() 82 | -------------------------------------------------------------------------------- /airgun/entities/os.py: -------------------------------------------------------------------------------- 1 | from navmazing import NavigateToSibling 2 | 3 | from airgun.entities.base import BaseEntity 4 | from airgun.navigation import NavigateStep, navigator 5 | from airgun.utils import retry_navigation 6 | from airgun.views.os import ( 7 | OperatingSystemCreateView, 8 | OperatingSystemEditView, 9 | OperatingSystemsView, 10 | ) 11 | 12 | 13 | class OperatingSystemEntity(BaseEntity): 14 | endpoint_path = '/operatingsystems' 15 | 16 | def create(self, values): 17 | """Create new operating system entity""" 18 | view = self.navigate_to(self, 'New') 19 | view.fill(values) 20 | view.submit.click() 21 | view.flash.assert_no_error() 22 | view.flash.dismiss() 23 | 24 | def delete(self, entity_name, cancel=False): 25 | """Remove existing operating system entity""" 26 | view = self.navigate_to(self, 'All') 27 | view.search(entity_name) 28 | view.table.row(title__endswith=entity_name)['Actions'].widget.fill('Delete') 29 | self.browser.handle_alert(cancel=cancel) 30 | view.flash.assert_no_error() 31 | view.flash.dismiss() 32 | 33 | def search(self, value): 34 | """Search for operating system entity""" 35 | view = self.navigate_to(self, 'All') 36 | return view.search(value) 37 | 38 | def read(self, entity_name, widget_names=None): 39 | """Read all values for created operating system entity""" 40 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 41 | return view.read(widget_names=widget_names) 42 | 43 | def update(self, entity_name, values): 44 | """Update necessary values for operating system""" 45 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 46 | view.fill(values) 47 | view.submit.click() 48 | view.flash.assert_no_error() 49 | view.flash.dismiss() 50 | 51 | 52 | @navigator.register(OperatingSystemEntity, 'All') 53 | class ShowAllOperatingSystems(NavigateStep): 54 | """Navigate to All Operating Systems page""" 55 | 56 | VIEW = OperatingSystemsView 57 | 58 | @retry_navigation 59 | def step(self, *args, **kwargs): 60 | self.view.menu.select('Hosts', 'Provisioning Setup', 'Operating Systems') 61 | 62 | 63 | @navigator.register(OperatingSystemEntity, 'New') 64 | class AddNewOperatingSystem(NavigateStep): 65 | """Navigate to Create Operating System page""" 66 | 67 | VIEW = OperatingSystemCreateView 68 | 69 | prerequisite = NavigateToSibling('All') 70 | 71 | def step(self, *args, **kwargs): 72 | self.parent.new.click() 73 | 74 | 75 | @navigator.register(OperatingSystemEntity, 'Edit') 76 | class EditOperatingSystem(NavigateStep): 77 | """Navigate to Edit Operating System page 78 | 79 | Args: 80 | entity_name: name of the operating system 81 | """ 82 | 83 | VIEW = OperatingSystemEditView 84 | 85 | def prerequisite(self, *args, **kwargs): 86 | return self.navigate_to(self.obj, 'All') 87 | 88 | def step(self, *args, **kwargs): 89 | entity_name = kwargs.get('entity_name') 90 | self.parent.search(entity_name) 91 | self.parent.table.row(title__endswith=entity_name)['Title'].widget.click() 92 | -------------------------------------------------------------------------------- /airgun/views/usergroup.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Checkbox, Table, Text, TextInput, View 2 | from widgetastic_patternfly import BreadCrumb 3 | from widgetastic_patternfly5 import Button 4 | 5 | from airgun.views.common import BaseLoggedInView, SatTab, SearchableViewMixinPF4 6 | from airgun.widgets import FilteredDropdown, MultiSelect 7 | 8 | 9 | class UserGroupsView(BaseLoggedInView, SearchableViewMixinPF4): 10 | title = Text("//h1[normalize-space(.)='User Groups']") 11 | new_on_blank_page = Button('Create User group') 12 | new = Text("//a[contains(@href, '/usergroups/new')]") 13 | table = Table( 14 | './/table', 15 | column_widgets={ 16 | 'Name': Text('./a'), 17 | 'Actions': Text('.//a[@data-method="delete"]'), 18 | }, 19 | ) 20 | 21 | @property 22 | def is_displayed(self): 23 | return self.browser.wait_for_element(self.title, exception=False) is not None 24 | 25 | 26 | class UserGroupDetailsView(BaseLoggedInView): 27 | breadcrumb = BreadCrumb() 28 | submit = Text('//input[@name="commit"]') 29 | 30 | @property 31 | def is_displayed(self): 32 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 33 | return ( 34 | breadcrumb_loaded 35 | and self.breadcrumb.locations[0] == 'User Groups' 36 | and self.breadcrumb.read().startswith('Edit ') 37 | ) 38 | 39 | @View.nested 40 | class usergroup(SatTab): 41 | TAB_NAME = 'User Group' 42 | 43 | name = TextInput(id='usergroup_name') 44 | usergroups = MultiSelect(id='ms-usergroup_usergroup_ids') 45 | users = MultiSelect(id='ms-usergroup_user_ids') 46 | 47 | @View.nested 48 | class roles(SatTab): 49 | admin = Checkbox(id='usergroup_admin') 50 | resources = MultiSelect(id='ms-usergroup_role_ids') 51 | 52 | @View.nested 53 | class external_groups(SatTab): 54 | TAB_NAME = 'External Groups' 55 | table = Table( 56 | './/table', 57 | column_widgets={ 58 | 'Actions': Text('.//a[contains(@href, "refresh")]'), 59 | }, 60 | ) 61 | 62 | add_external_user_group = Text('.//a[@data-association="external_usergroups"]') 63 | name = TextInput( 64 | locator=( 65 | "(//input[starts-with(@name, 'usergroup[external_usergroups_attributes]')]" 66 | "[contains(@name, '[name]')])[last()]" 67 | ) 68 | ) 69 | auth_source = FilteredDropdown( 70 | # this locator fails when there are multiple user groups, it doesn't specify which 71 | locator=("//span[contains(@class, 'select2-selection__rendered')]") 72 | ) 73 | 74 | def before_fill(self, values): 75 | self.add_external_user_group.click() 76 | 77 | 78 | class UserGroupCreateView(UserGroupDetailsView): 79 | @property 80 | def is_displayed(self): 81 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 82 | return ( 83 | breadcrumb_loaded 84 | and self.breadcrumb.locations[0] == 'User Groups' 85 | and self.breadcrumb.read() == 'Create User group' 86 | ) 87 | -------------------------------------------------------------------------------- /airgun/entities/bookmark.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.navigation import NavigateStep, navigator 3 | from airgun.utils import retry_navigation 4 | from airgun.views.bookmark import BookmarkEditView, BookmarksView 5 | 6 | 7 | def _gen_queries(entity_name, controller=None): 8 | """Generate search query and row filtering query from bookmark name and 9 | controller if passed. 10 | """ 11 | row_query = {'name': entity_name} 12 | search_query = f'name = "{entity_name}"' 13 | if controller: 14 | search_query = f'{search_query} and controller = "{controller}"' 15 | row_query['controller'] = controller 16 | return search_query, row_query 17 | 18 | 19 | class BookmarkEntity(BaseEntity): 20 | endpoint_path = '/bookmarks' 21 | 22 | # Note: creation procedure takes place on specific entity page, generic 23 | # helper is implemented inside :class:`BaseEntity`. 24 | 25 | def delete(self, entity_name, controller=None): 26 | """Delete existing bookmark""" 27 | view = self.navigate_to(self, 'All') 28 | query, row_query = _gen_queries(entity_name, controller) 29 | view.search(query) 30 | view.table.row(**row_query)['Actions'].widget.click(handle_alert=True) 31 | view.flash.assert_no_error() 32 | view.flash.dismiss() 33 | 34 | def search(self, query): 35 | """Search for bookmark""" 36 | view = self.navigate_to(self, 'All') 37 | return view.search(query) 38 | 39 | def read(self, entity_name, controller=None, widget_names=None): 40 | """Read bookmark values""" 41 | view = self.navigate_to(self, 'Edit', entity_name=entity_name, controller=controller) 42 | return view.read(widget_names=widget_names) 43 | 44 | def update(self, entity_name, values, controller=None): 45 | """Update existing bookmark""" 46 | view = self.navigate_to(self, 'Edit', entity_name=entity_name, controller=controller) 47 | result = view.fill(values) 48 | view.submit.click() 49 | view.flash.assert_no_error() 50 | view.flash.dismiss() 51 | return result 52 | 53 | 54 | @navigator.register(BookmarkEntity, 'All') 55 | class ShowAllBookmarks(NavigateStep): 56 | """Navigate to All Bookmarks screen.""" 57 | 58 | VIEW = BookmarksView 59 | 60 | @retry_navigation 61 | def step(self, *args, **kwargs): 62 | self.view.menu.select('Administer', 'Bookmarks') 63 | 64 | 65 | @navigator.register(BookmarkEntity, 'Edit') 66 | class EditBookmark(NavigateStep): 67 | """Navigate to Edit Bookmark screen.""" 68 | 69 | VIEW = BookmarkEditView 70 | 71 | def prerequisite(self, *args, **kwargs): 72 | return self.navigate_to(self.obj, 'All') 73 | 74 | def step(self, *args, **kwargs): 75 | """Using a given entity_name, navigate to edit page from prerequisite 76 | 77 | :param entity_name: name of bookmark 78 | :param controller: (optional) name of controller for bookmark 79 | """ 80 | entity_name = kwargs.get('entity_name') 81 | controller = kwargs.get('controller') 82 | query, row_query = _gen_queries(entity_name, controller) 83 | self.parent.search(query) 84 | self.parent.table.row(**row_query)['Name'].widget.click() 85 | -------------------------------------------------------------------------------- /airgun/views/partitiontable.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import ( 2 | Checkbox, 3 | ConditionalSwitchableView, 4 | Table, 5 | Text, 6 | TextInput, 7 | View, 8 | ) 9 | from widgetastic_patternfly import BreadCrumb, Button 10 | 11 | from airgun.views.common import ( 12 | BaseLoggedInView, 13 | SatTab, 14 | SearchableViewMixinPF4, 15 | TemplateEditor, 16 | TemplateInputItem, 17 | ) 18 | from airgun.widgets import ( 19 | ActionsDropdown, 20 | FilteredDropdown, 21 | MultiSelect, 22 | RemovableWidgetsItemsListView, 23 | ) 24 | 25 | 26 | class PartitionTablesView(BaseLoggedInView, SearchableViewMixinPF4): 27 | title = Text("//h1[text()='Partition Tables']") 28 | new = Button('Create Partition Table') 29 | table = Table( 30 | './/table', 31 | column_widgets={ 32 | 'Name': Text('./a'), 33 | 'Actions': ActionsDropdown("./div[contains(@class, 'btn-group')]"), 34 | }, 35 | ) 36 | 37 | @property 38 | def is_displayed(self): 39 | return self.browser.wait_for_element(self.title, exception=False) is not None 40 | 41 | 42 | class PartitionTableEditView(BaseLoggedInView): 43 | breadcrumb = BreadCrumb() 44 | submit = Text('//input[@name="commit"]') 45 | 46 | @View.nested 47 | class template(SatTab): 48 | name = TextInput(id='ptable_name') 49 | default = Checkbox(id='ptable_default') 50 | snippet = Checkbox(locator="//input[@id='ptable_snippet']") 51 | os_family_selection = ConditionalSwitchableView(reference='snippet') 52 | 53 | @os_family_selection.register(True) 54 | class SnippetOption(View): 55 | pass 56 | 57 | @os_family_selection.register(False) 58 | class OSFamilyOption(View): 59 | os_family = FilteredDropdown(id='ptable_os_family') 60 | 61 | template_editor = TemplateEditor() 62 | audit_comment = TextInput(id='ptable_audit_comment') 63 | 64 | @View.nested 65 | class inputs(RemovableWidgetsItemsListView, SatTab): 66 | ITEMS = ".//div[contains(@class, 'template_inputs')]/following-sibling::div" 67 | ITEM_WIDGET_CLASS = TemplateInputItem 68 | add_item_button = Text(".//a[@data-association='template_inputs']") 69 | 70 | @View.nested 71 | class locations(SatTab): 72 | resources = MultiSelect(id='ms-ptable_location_ids') 73 | 74 | @View.nested 75 | class organizations(SatTab): 76 | resources = MultiSelect(id='ms-ptable_organization_ids') 77 | 78 | @property 79 | def is_displayed(self): 80 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 81 | return ( 82 | breadcrumb_loaded 83 | and self.breadcrumb.locations[0] == 'Partition Tables' 84 | and self.breadcrumb.read().startswith('Edit ') 85 | ) 86 | 87 | 88 | class PartitionTableCreateView(PartitionTableEditView): 89 | @property 90 | def is_displayed(self): 91 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 92 | return ( 93 | breadcrumb_loaded 94 | and self.breadcrumb.locations[0] == 'Partition Tables' 95 | and self.breadcrumb.read() == 'Create Partition Table' 96 | ) 97 | -------------------------------------------------------------------------------- /airgun/views/oscapreport.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Text, View 2 | from widgetastic_patternfly4 import Button 3 | from widgetastic_patternfly5.ouia import FormSelect as PF5FormSelect 4 | 5 | from airgun.views.common import BaseLoggedInView, SearchableViewMixin, WizardStepView 6 | from airgun.widgets import ( 7 | ActionsDropdown, 8 | SatTable, 9 | ) 10 | 11 | 12 | class SCAPReportView(BaseLoggedInView, SearchableViewMixin): 13 | title = Text("//h1[normalize-space(.)='Compliance Reports']") 14 | table = SatTable( 15 | './/table', 16 | column_widgets={ 17 | 'Host': Text(".//a[contains(@href,'/new/hosts')]"), 18 | 'Reported At': Text(".//a[contains(@href,'/compliance/arf_reports')]"), 19 | 'Policy': Text(".//a[contains(@href,'/compliance/policies')]"), 20 | 'Openscap Capsule': Text(".//a[contains(@href,'/smart_proxies')]"), 21 | 'Passed': Text(".//span[contains(@class,'label-info')]"), 22 | 'Failed': Text(".//span[contains(@class,'label-danger')]"), 23 | 'Other': Text(".//span[contains(@class,'label-warning')]"), 24 | 'Actions': ActionsDropdown("./div[contains(@class, 'btn-group')]"), 25 | }, 26 | ) 27 | 28 | @property 29 | def is_displayed(self): 30 | return self.browser.wait_for_element(self.title, exception=False) is not None 31 | 32 | 33 | class SCAPReportDetailsView(BaseLoggedInView): 34 | show_log_messages_label = Text('//span[normalize-space(.)="Show log messages:"]') 35 | table = SatTable( 36 | './/table', 37 | column_widgets={ 38 | 'Result': Text('./span[1]'), 39 | 'Message': Text('./span[2]'), 40 | 'Resource': Text('./span[3]'), 41 | 'Severity': Text('./img[1]'), 42 | 'Actions': ActionsDropdown("./div[contains(@class, 'btn-group')]"), 43 | }, 44 | ) 45 | 46 | @property 47 | def is_displayed(self): 48 | return ( 49 | self.browser.wait_for_element(self.show_log_messages_label, exception=False) is not None 50 | ) 51 | 52 | 53 | class RemediateModal(View): 54 | """ 55 | Class representing the "Remediate" modal. 56 | It contains multiple nested classes each representing a step of the wizard. 57 | """ 58 | 59 | ROOT = '//div[contains(@data-ouia-component-id, "OUIA-Generated-Modal-large-")]' 60 | 61 | title = Text('.//h2[contains(@class, "pf-v5-c-wizard__title-text")]') 62 | close_modal = Button(locator='.//button[@aria-label="Close"]') 63 | 64 | @View.nested 65 | class select_remediation_method(WizardStepView): 66 | expander = Text( 67 | './/button[contains(@class,"pf-v5-c-wizard__nav-link") and contains(.,"Select snippet")]' 68 | ) 69 | snippet = PF5FormSelect('snippet-select') 70 | 71 | @View.nested 72 | class name_source(WizardStepView): 73 | expander = Text( 74 | './/button[contains(@class,"pf-v5-c-wizard__nav-link") and contains(.,"Review hosts")]' 75 | ) 76 | host_table = SatTable('.//table') 77 | 78 | @View.nested 79 | class select_capsule(WizardStepView): 80 | expander = Text( 81 | './/button[contains(@class,"pf-v5-c-wizard__nav-link") and contains(.,"Review remediation")]' 82 | ) 83 | run = Button(locator='.//button[normalize-space(.)="Run"]') 84 | -------------------------------------------------------------------------------- /airgun/views/cloud_vulnerabilities.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Text 2 | 3 | # from widgetastic_patternfly5.ouia import ( 4 | # ExpandableTable as PF5OUIAExpandableTable, 5 | # PatternflyTable as PF5OUIAPatternflyTable, 6 | # ) 7 | from widgetastic_patternfly5 import ( 8 | Button as PF5Button, 9 | ExpandableTable as PF5OUIAExpandableTable, 10 | Pagination as PF5Pagination, 11 | PatternflyTable as PF5OUIAPatternflyTable, 12 | ) 13 | 14 | from airgun.views.common import BaseLoggedInView 15 | from airgun.widgets import SearchInput 16 | 17 | 18 | class CloudVulnerabilityView(BaseLoggedInView): 19 | """Main Insights Vulnerabilities view.""" 20 | 21 | title = Text('//h1[normalize-space(.)="Vulnerabilities"]') 22 | cves_with_known_exploits_card = PF5Button( 23 | '//div[@data-ouia-component-type="PF5/Card"][.//b[text()="CVEs with known exploits"]]' 24 | ) 25 | cves_with_security_rules_card = PF5Button( 26 | '//div[@data-ouia-component-type="PF5/Card"][.//b[text()="CVEs with security rules"]]' 27 | ) 28 | cves_with_critical_severity_card = PF5Button( 29 | '//div[@data-ouia-component-type="PF5/Card"][.//b[text()="CVEs with critical severity"]]' 30 | ) 31 | cves_with_important_severity_card = PF5Button( 32 | '//div[@data-ouia-component-type="PF5/Card"][.//b[text()="CVEs with important severity"]]' 33 | ) 34 | search_bar = SearchInput(locator='.//input[contains(@aria-label, "search-field")]') 35 | cve_menu_toggle = PF5Button('.//button[contains(@class, "pf-v5-c-menu-toggle")]') 36 | no_cves_found_message = Text('.//h5[contains(@class, "pf-v5-c-empty-state__title-text")]') 37 | 38 | vulnerabilities_table = PF5OUIAExpandableTable( 39 | # component_id='OUIA-Generated-Table-1', 40 | locator='.//table[contains(@class, "pf-v5-c-table")]', 41 | column_widgets={ 42 | 0: PF5Button(locator='.//button[@aria-label="Details"]'), 43 | 'CVE ID': Text('.//td[@data-label="CVE ID"]'), 44 | 'Publish date': Text('.//td[@data-label="Publish date"]'), 45 | 'Severity': Text('.//td[@data-label="Severity"]'), 46 | 'CVSS base score': Text('.//td[@data-label="CVSS base score"]'), 47 | 'Affected hosts': Text('.//td[@data-label="Affected hosts"]'), 48 | }, 49 | ) 50 | pagination = PF5Pagination() 51 | 52 | @property 53 | def is_displayed(self): 54 | return self.browser.wait_for_element(self.title, exception=False) is not None 55 | 56 | 57 | class CVEDetailsView(BaseLoggedInView): 58 | """Class that describes the Vulnerabilities Details page""" 59 | 60 | title = Text('.//h1[@data-ouia-component-type="RHI/Header"]') 61 | description = Text('.//div[@class="pf-v5-c-content"]') 62 | search_bar = SearchInput(locator='.//input[contains(@aria-label, "search-field")]') 63 | affected_hosts_table = PF5OUIAPatternflyTable( 64 | # component_id='OUIA-Generated-Table-1', 65 | locator='.//table[contains(@class, "pf-v5-c-table")]', 66 | column_widgets={ 67 | 'Name': Text('./a'), 68 | 'OS': Text('.//td[contains(@data-label, "OS")]'), 69 | 'Last seen': Text('.//td[contains(@data-label, "Last seen")]'), 70 | }, 71 | ) 72 | 73 | @property 74 | def is_displayed(self): 75 | return self.browser.wait_for_element(self.title, exception=False) is not None 76 | -------------------------------------------------------------------------------- /airgun/entities/package.py: -------------------------------------------------------------------------------- 1 | from airgun.entities.base import BaseEntity 2 | from airgun.navigation import NavigateStep, navigator 3 | from airgun.utils import retry_navigation 4 | from airgun.views.package import PackageDetailsView, PackagesView 5 | 6 | 7 | class PackageEntity(BaseEntity): 8 | endpoint_path = '/packages' 9 | 10 | def search(self, query, repository='All Repositories', applicable=False, upgradable=False): 11 | """Search for package in the indicated repository 12 | 13 | :param str query: search query to type into search field. E.g. 14 | ``name = "bar"``. 15 | :param str repository: repository name to select when searching for the 16 | package. 17 | :param bool applicable: To show only applicable packages. 18 | :param bool upgradable: To show only upgradable packages. 19 | """ 20 | view = self.navigate_to(self, 'All') 21 | return view.search( 22 | query, repository=repository, applicable=applicable, upgradable=upgradable 23 | ) 24 | 25 | def read(self, entity_name, repository='All Repositories', widget_names=None): 26 | """Read package values from Package Details page 27 | 28 | :param str entity_name: the package name to read. 29 | :param str repository: repository name to select when searching for the 30 | package. 31 | """ 32 | view = self.navigate_to(self, 'Details', entity_name=entity_name, repository=repository) 33 | return view.read(widget_names=widget_names) 34 | 35 | def click_install_on_link(self, entity_name, repository='All Repositories'): 36 | """Click on host link 'Installed On' which is present on Package detail tab 37 | 38 | :param str entity_name: the package name to read. 39 | :param str repository: repository name to select when searching for the 40 | package. 41 | """ 42 | view = self.navigate_to(self, 'Details', entity_name=entity_name, repository=repository) 43 | view.install_on_host_link.click() 44 | 45 | 46 | @navigator.register(PackageEntity, 'All') 47 | class ShowAllPackages(NavigateStep): 48 | """navigate to Packages Page""" 49 | 50 | VIEW = PackagesView 51 | 52 | @retry_navigation 53 | def step(self, *args, **kwargs): 54 | self.view.menu.select('Content', 'Content Types', 'Packages') 55 | 56 | 57 | @navigator.register(PackageEntity, 'Details') 58 | class ShowPackageDetails(NavigateStep): 59 | """Navigate to Package Details page by clicking on necessary package name 60 | in the table 61 | 62 | Args: 63 | entity_name: The package name. 64 | repository: The package repository name. 65 | """ 66 | 67 | VIEW = PackageDetailsView 68 | 69 | def prerequisite(self, *args, **kwargs): 70 | return self.navigate_to(self.obj, 'All') 71 | 72 | def step(self, *args, **kwargs): 73 | entity_name = kwargs.get('entity_name') 74 | repository = kwargs.get('repository', 'All Repositories') 75 | self.parent.search(f'name = {entity_name}', repository=repository) 76 | self.parent.table.row(('RPM', 'startswith', entity_name))['RPM'].widget.click() 77 | 78 | def am_i_here(self, *args, **kwargs): 79 | entity_name = kwargs.get('entity_name') 80 | self.view.package_name = entity_name 81 | return self.view.is_displayed and self.view.breadcrumb.locations[1].startswith(entity_name) 82 | -------------------------------------------------------------------------------- /.github/workflows/prt_result.yml: -------------------------------------------------------------------------------- 1 | ### The prt result workflow triggered through dispatch request from CI 2 | name: post-prt-result 3 | 4 | # Run on workflow dispatch from CI 5 | on: 6 | workflow_dispatch: 7 | inputs: 8 | pr_number: 9 | type: string 10 | description: pr number for PRT run 11 | build_number: 12 | type: string 13 | description: build number for PRT run 14 | pytest_result: 15 | type: string 16 | description: pytest summary result line 17 | build_status: 18 | type: string 19 | description: status of jenkins build e.g. success, unstable or error 20 | prt_comment: 21 | type: string 22 | description: prt pytest comment triggered the PRT checks 23 | 24 | 25 | jobs: 26 | post-the-prt-result: 27 | runs-on: ubuntu-latest 28 | 29 | steps: 30 | - name: Add last PRT result into the github comment 31 | id: add-prt-comment 32 | if: ${{ always() && github.event.inputs.pytest_result != '' }} 33 | uses: thollander/actions-comment-pull-request@v3 34 | with: 35 | message: | 36 | **PRT Result** 37 | ``` 38 | Build Number: ${{ github.event.inputs.build_number }} 39 | Build Status: ${{ github.event.inputs.build_status }} 40 | PRT Comment: ${{ github.event.inputs.prt_comment }} 41 | Test Result : ${{ github.event.inputs.pytest_result }} 42 | ``` 43 | pr_number: ${{ github.event.inputs.pr_number }} 44 | GITHUB_TOKEN: ${{ secrets.CHERRYPICK_PAT }} 45 | 46 | - name: Add the PRT passed/failed labels 47 | id: prt-status 48 | if: ${{ always() && github.event.inputs.build_status != '' }} 49 | uses: actions/github-script@v8 50 | with: 51 | github-token: ${{ secrets.CHERRYPICK_PAT }} 52 | script: | 53 | const prNumber = ${{ github.event.inputs.pr_number }}; 54 | const buildStatus = "${{ github.event.inputs.build_status }}"; 55 | const labelToAdd = buildStatus === "SUCCESS" ? "PRT-Passed" : "PRT-Failed"; 56 | github.rest.issues.addLabels({ 57 | issue_number: prNumber, 58 | owner: context.repo.owner, 59 | repo: context.repo.repo, 60 | labels: [labelToAdd] 61 | }); 62 | - name: Remove failed label on test pass or vice-versa 63 | if: ${{ always() && github.event.inputs.build_status != '' }} 64 | uses: actions/github-script@v8 65 | with: 66 | github-token: ${{ secrets.CHERRYPICK_PAT }} 67 | script: | 68 | const prNumber = ${{ github.event.inputs.pr_number }}; 69 | const issue = await github.rest.issues.get({ 70 | owner: context.issue.owner, 71 | repo: context.issue.repo, 72 | issue_number: prNumber, 73 | }); 74 | const buildStatus = "${{ github.event.inputs.build_status }}"; 75 | const labelToRemove = buildStatus === "SUCCESS" ? "PRT-Failed" : "PRT-Passed"; 76 | const labelExists = issue.data.labels.some(({ name }) => name === labelToRemove); 77 | if (labelExists) { 78 | github.rest.issues.removeLabel({ 79 | issue_number: prNumber, 80 | owner: context.repo.owner, 81 | repo: context.repo.repo, 82 | name: [labelToRemove] 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /airgun/entities/cloud_vulnerabilities.py: -------------------------------------------------------------------------------- 1 | from wait_for import wait_for 2 | 3 | from airgun.entities.base import BaseEntity 4 | from airgun.navigation import NavigateStep, navigator 5 | from airgun.utils import retry_navigation 6 | from airgun.views.cloud_vulnerabilities import CloudVulnerabilityView, CVEDetailsView 7 | from airgun.views.host_new import NewHostDetailsView 8 | 9 | 10 | class CloudVulnerabilityEntity(BaseEntity): 11 | endpoint_path = '/foreman_rh_cloud/insights_vulnerability' 12 | 13 | def read(self, entity_name=None, widget_names=None): 14 | view = self.navigate_to(self, 'All') 15 | wait_for(lambda: view.vulnerabilities_table.is_displayed, timeout=30) 16 | return view.vulnerabilities_table.read() 17 | 18 | def _navigate_to_cve_details(self, cve_id): 19 | """Helper method to navigate to CVE details page""" 20 | view = self.navigate_to(self, 'All') 21 | view.wait_displayed() 22 | wait_for(lambda: view.vulnerabilities_table.is_displayed, timeout=30) 23 | view.search_bar.fill(cve_id) 24 | view.browser.element(f'.//a[contains(@href, "{cve_id}")]').click() 25 | 26 | def get_cve_details(self, cve_id): 27 | """ 28 | Read CVE details from CVE details page 29 | 30 | Args: 31 | cve_id (str): CVE ID to get details 32 | """ 33 | self._navigate_to_cve_details(cve_id) 34 | view = CVEDetailsView(self.browser) 35 | return view.read() 36 | 37 | def get_affected_hosts_by_cve(self, cve_id): 38 | """ 39 | Get list of affected hosts for a specific CVE 40 | 41 | Args: 42 | cve_id (str): CVE ID to get affected hosts for 43 | """ 44 | self._navigate_to_cve_details(cve_id) 45 | cve_details_view = CVEDetailsView(self.browser) 46 | wait_for(lambda: cve_details_view.affected_hosts_table.is_displayed, timeout=30) 47 | return cve_details_view.affected_hosts_table.read() 48 | 49 | def validate_cve_to_host_details_flow(self, cve_id, hostname=None): 50 | """ 51 | Complete flow: CVE details -> affected hosts -> click host -> host details page 52 | 53 | Args: 54 | cve_id (str): CVE ID to test 55 | hostname (str, optional): Specific host name to click on. 56 | """ 57 | self._navigate_to_cve_details(cve_id) 58 | cve_details_view = CVEDetailsView(self.browser) 59 | wait_for(lambda: cve_details_view.affected_hosts_table.is_displayed, timeout=30) 60 | cve_details_view.search_bar.fill(hostname) 61 | cve_details_view.browser.element(f'.//a[contains(text(), "{hostname}")]').click() 62 | host_details_view = NewHostDetailsView(self.browser) 63 | host_details_view.breadcrumb.wait_displayed() 64 | wait_for( 65 | lambda: host_details_view.vulnerabilities.vulnerabilities_table.is_displayed, timeout=30 66 | ) 67 | vulnerabilities = getattr(host_details_view.vulnerabilities, 'vulnerabilities_table', None) 68 | if vulnerabilities is not None: 69 | return vulnerabilities.read() 70 | else: 71 | return [] 72 | 73 | 74 | @navigator.register(CloudVulnerabilityEntity, 'All') 75 | class ShowVulnerabilityListView(NavigateStep): 76 | """Navigate to main Red Hat Lightspeed -> Vulnerability page""" 77 | 78 | VIEW = CloudVulnerabilityView 79 | 80 | @retry_navigation 81 | def step(self, *args, **kwargs): 82 | self.view.menu.select('Red Hat Lightspeed', 'Vulnerability') 83 | -------------------------------------------------------------------------------- /airgun/utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import time 3 | 4 | from wait_for import TimedOutError 5 | 6 | 7 | def merge_dict(values, new_values): 8 | """Update dict values with new values from new_values dict 9 | 10 | Merge example: 11 | 12 | a = {'a': {'c': {'k': 2, 'x': {1: 0}}}} 13 | b = {'a': {'c': {'z': 5, 'y': 40, 'x': {2: 1}}}, 'b': {'a': 1, 'l': 2}} 14 | update_dict(a, b) 15 | # a updated and equal: 16 | # {'a': {'c': {'k': 2, 'x': {1: 0, 2: 1}, 'z': 5, 'y': 40}}, 'b': {'a': 1, 'l': 2}} 17 | """ 18 | for key in new_values: 19 | if key in values and isinstance(values[key], dict) and isinstance(new_values[key], dict): 20 | merge_dict(values[key], new_values[key]) 21 | else: 22 | values[key] = new_values[key] 23 | 24 | 25 | def normalize_dict_values(values): 26 | """Transform a widget path:value dict to a regular View read values format. 27 | 28 | This function transform a dictionary from: 29 | {'a.b': 1, 'a.z': 10, 'a.c.k': 2, 'a.c.z': 5, 'x.y': 3, 'c': 4} 30 | to: 31 | {'a': {'b': 1, 'z': 10, 'c': {'k': 2, 'z': 5}}, 'x': {'y': 3}, 'c': 4} 32 | """ 33 | new_values = {} 34 | for key, value in values.items(): 35 | keys = key.split('.') 36 | new_key = keys.pop(0) 37 | if keys: 38 | new_key_value = normalize_dict_values({'.'.join(keys): value}) 39 | else: 40 | new_key_value = value 41 | if ( 42 | new_key in new_values 43 | and isinstance(new_values[new_key], dict) 44 | and isinstance(new_key_value, dict) 45 | ): 46 | # merge in place the new_values with new_key_value 47 | merge_dict(new_values[new_key], new_key_value) 48 | else: 49 | new_values[new_key] = new_key_value 50 | return new_values 51 | 52 | 53 | def get_widget_by_name(widget_root, widget_name): 54 | """Return a widget by it's name from widget_root, where widget can be a sub widget. 55 | 56 | :param widget_root: The root Widget instance from where to begin resolving widget_name. 57 | :param widget_name: a string representation of the widget instance to find. 58 | 59 | Example: 60 | widget_name = 'details' 61 | or 62 | widget_name = 'details.subscription_status' 63 | or 64 | widget_name = 'details.subscriptions.resources' 65 | """ 66 | widget = widget_root 67 | for sub_widget_name in widget_name.split('.'): 68 | name = sub_widget_name 69 | if name not in widget.widget_names: 70 | name = name.replace(' ', '_') 71 | name = name.lower() 72 | if name not in widget.widget_names: 73 | raise AttributeError( 74 | f'Object <{widget.__class__}> has no widget name "{sub_widget_name}"' 75 | ) 76 | widget = getattr(widget, name) 77 | return widget 78 | 79 | 80 | def retry_navigation(method): 81 | """Decorator to invoke method one or more times, if TimedOutError is raised.""" 82 | 83 | @functools.wraps(method) 84 | def retry_wrapper(*args, **kwargs): 85 | attempts = 3 86 | for i in range(attempts): 87 | try: 88 | return method(*args, **kwargs) 89 | except TimedOutError: 90 | if i < attempts - 1: 91 | args[0].view.parent.browser.refresh() 92 | time.sleep(0.5) 93 | else: 94 | raise 95 | 96 | return retry_wrapper 97 | -------------------------------------------------------------------------------- /airgun/views/package.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Checkbox, Select, Text, View 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import ( 5 | BaseLoggedInView, 6 | ReadOnlyEntry, 7 | SatTab, 8 | SatTable, 9 | ) 10 | from airgun.widgets import ItemsListReadOnly, Search 11 | 12 | 13 | class PackagesView(BaseLoggedInView): 14 | """Main Packages view""" 15 | 16 | title = Text("//h2[contains(., 'Packages')]") 17 | table = SatTable('.//table', column_widgets={'RPM': Text('./a')}) 18 | 19 | repository = Select(locator=".//select[@ng-model='repository']") 20 | applicable = Checkbox(locator=".//input[@ng-model='showApplicable']") 21 | upgradable = Checkbox(locator=".//input[@ng-model='showUpgradable']") 22 | search_box = Search() 23 | 24 | def search(self, query, repository='All Repositories', applicable=False, upgradable=False): 25 | """Perform search using search box on the page and return table 26 | contents. 27 | 28 | :param str query: search query to type into search field. E.g. 29 | ``name = "bar"``. 30 | :param str repository: repository name to select when searching for the 31 | package. 32 | :param bool applicable: To show only applicable packages 33 | :param bool upgradable: To show only upgradable packages 34 | :return: list of dicts representing table rows 35 | :rtype: list 36 | """ 37 | self.repository.fill(repository) 38 | # set the upgradable first as if enabled, applicable element will be 39 | # disabled 40 | self.upgradable.fill(upgradable) 41 | if not upgradable: 42 | self.applicable.fill(applicable) 43 | self.search_box.search(query) 44 | return self.table.read() 45 | 46 | @property 47 | def is_displayed(self): 48 | """The view is displayed when it's title exists""" 49 | return self.browser.wait_for_element(self.title, exception=False) is not None 50 | 51 | 52 | class PackageDetailsView(BaseLoggedInView): 53 | breadcrumb = BreadCrumb() 54 | install_on_host_link = Text(locator='*//span[contains(@ng-show, "installedPackageCount")]') 55 | 56 | @property 57 | def is_displayed(self): 58 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 59 | 60 | return breadcrumb_loaded and self.breadcrumb.locations[0] == 'Packages' 61 | 62 | @View.nested 63 | class details(SatTab): 64 | # Package Information: 65 | installed_on = ReadOnlyEntry(name='Installed On') 66 | applicable_to = ReadOnlyEntry(name='Applicable To') 67 | upgradable_for = ReadOnlyEntry(name='Upgradable For') 68 | description = ReadOnlyEntry(name='Description') 69 | summary = ReadOnlyEntry(name='Summary') 70 | group = ReadOnlyEntry(name='Group') 71 | license = ReadOnlyEntry(name='License') 72 | url = ReadOnlyEntry(name='Url') 73 | # File Information: 74 | size = ReadOnlyEntry(name='Size') 75 | filename = ReadOnlyEntry(name='Filename') 76 | checksum = ReadOnlyEntry(name='Checksum') 77 | checksum_type = ReadOnlyEntry(name='Checksum Type') 78 | # Build Information: 79 | source_rpm = ReadOnlyEntry(name='Source RPM') 80 | build_host = ReadOnlyEntry(name='Build Host') 81 | build_time = ReadOnlyEntry(name='Build Time') 82 | 83 | @View.nested 84 | class files(SatTab): 85 | package_files = ItemsListReadOnly(locator=".//div[@data-block='content']//ul") 86 | -------------------------------------------------------------------------------- /airgun/entities/hardware_model.py: -------------------------------------------------------------------------------- 1 | from navmazing import NavigateToSibling 2 | 3 | from airgun.entities.base import BaseEntity 4 | from airgun.navigation import NavigateStep, navigator 5 | from airgun.utils import retry_navigation 6 | from airgun.views.hardware_model import ( 7 | HardwareModelCreateView, 8 | HardwareModelEditView, 9 | HardwareModelsView, 10 | ) 11 | 12 | 13 | class HardwareModelEntity(BaseEntity): 14 | endpoint_path = '/models' 15 | 16 | def create(self, values): 17 | """Create new hardware model""" 18 | view = self.navigate_to(self, 'New') 19 | view.fill(values) 20 | view.submit.click() 21 | view.flash.assert_no_error() 22 | view.flash.dismiss() 23 | 24 | def search(self, value): 25 | """Search for specific hardware model""" 26 | view = self.navigate_to(self, 'All') 27 | return view.search(value) 28 | 29 | def read(self, entity_name, widget_names=None): 30 | """Read values for existing hardware model""" 31 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 32 | return view.read(widget_names=widget_names) 33 | 34 | def update(self, entity_name, values): 35 | """Update hardware model values""" 36 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 37 | view.fill(values) 38 | view.submit.click() 39 | view.flash.assert_no_error() 40 | view.flash.dismiss() 41 | 42 | def delete(self, entity_name, err_message=''): 43 | """Delete hardware model 44 | 45 | err_message - expected when dialog throws an error, error message is checked 46 | """ 47 | view = self.navigate_to(self, 'All') 48 | view.search(entity_name) 49 | view.table.row(name=entity_name)[4].widget.item_select('Delete') 50 | view.delete_dialog.confirm() 51 | if err_message: 52 | view.flash.wait_displayed() 53 | view.flash.assert_message(f'Danger alert: {err_message}') 54 | view.flash.dismiss() 55 | view.delete_dialog.cancel() 56 | else: 57 | view.flash.assert_no_error() 58 | view.flash.dismiss() 59 | 60 | 61 | @navigator.register(HardwareModelEntity, 'All') 62 | class ShowAllHardwareModels(NavigateStep): 63 | """Navigate to All Hardware Model screen.""" 64 | 65 | VIEW = HardwareModelsView 66 | 67 | @retry_navigation 68 | def step(self, *args, **kwargs): 69 | self.view.menu.select('Hosts', 'Provisioning Setup', 'Hardware Models') 70 | 71 | 72 | @navigator.register(HardwareModelEntity, 'New') 73 | class AddNewHardwareModel(NavigateStep): 74 | """Navigate to Create new Hardware Model screen.""" 75 | 76 | VIEW = HardwareModelCreateView 77 | 78 | prerequisite = NavigateToSibling('All') 79 | 80 | def step(self, *args, **kwargs): 81 | self.parent.new.click() 82 | 83 | 84 | @navigator.register(HardwareModelEntity, 'Edit') 85 | class EditHardwareModel(NavigateStep): 86 | """Navigate to Edit Hardware Model screen. 87 | 88 | Args: 89 | entity_name: name of hardware model 90 | """ 91 | 92 | VIEW = HardwareModelEditView 93 | 94 | def prerequisite(self, *args, **kwargs): 95 | return self.navigate_to(self.obj, 'All') 96 | 97 | def step(self, *args, **kwargs): 98 | entity_name = kwargs.get('entity_name') 99 | self.parent.search(entity_name) 100 | self.parent.table.row(name=entity_name)['Name'].widget.click() 101 | -------------------------------------------------------------------------------- /airgun/entities/user.py: -------------------------------------------------------------------------------- 1 | from navmazing import NavigateToSibling 2 | from wait_for import wait_for 3 | 4 | from airgun.entities.base import BaseEntity 5 | from airgun.navigation import NavigateStep, navigator 6 | from airgun.utils import retry_navigation 7 | from airgun.views.user import UserCreateView, UserDetailsView, UsersView 8 | 9 | 10 | class UserEntity(BaseEntity): 11 | endpoint_path = '/users' 12 | 13 | def create(self, values): 14 | """Create new user entity""" 15 | view = self.navigate_to(self, 'New') 16 | wait_for( 17 | lambda: UserCreateView(self.browser).is_displayed is True, 18 | timeout=60, 19 | delay=1, 20 | ) 21 | view.fill(values) 22 | view.submit.click() 23 | view.flash.assert_no_error() 24 | view.flash.dismiss() 25 | 26 | def search(self, value): 27 | """Search for user entity""" 28 | view = self.navigate_to(self, 'All') 29 | return view.search(value) 30 | 31 | def read(self, entity_name, widget_names=None): 32 | """Read all values for created user entity""" 33 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 34 | return view.read(widget_names=widget_names) 35 | 36 | def update(self, entity_name, values): 37 | """Update necessary values for user""" 38 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 39 | view.fill(values) 40 | view.submit.click() 41 | view.flash.assert_no_error() 42 | view.flash.dismiss() 43 | 44 | def delete(self, entity_name): 45 | """Remove existing user entity""" 46 | view = self.navigate_to(self, 'All') 47 | view.search(entity_name) 48 | view.table.row(username=entity_name)['Actions'].widget.click(handle_alert=True) 49 | view.flash.assert_no_error() 50 | view.flash.dismiss() 51 | 52 | def invalidate_jwt(self, entity_name): 53 | """Invalidate JSON Web Token of an user entity""" 54 | view = self.navigate_to(self, 'All') 55 | view.search(entity_name) 56 | if view.dropdown.is_displayed: 57 | view.dropdown.click() 58 | view.invalidate_jwt.click() 59 | view.dialog.confirm_dialog.click() 60 | view.flash.assert_no_error() 61 | view.flash.dismiss() 62 | 63 | 64 | @navigator.register(UserEntity, 'All') 65 | class ShowAllUsers(NavigateStep): 66 | """Navigate to All Users page""" 67 | 68 | VIEW = UsersView 69 | 70 | @retry_navigation 71 | def step(self, *args, **kwargs): 72 | self.view.menu.select('Administer', 'Users') 73 | 74 | 75 | @navigator.register(UserEntity, 'New') 76 | class AddNewUser(NavigateStep): 77 | """Navigate to Create User page""" 78 | 79 | VIEW = UserCreateView 80 | 81 | prerequisite = NavigateToSibling('All') 82 | 83 | def step(self, *args, **kwargs): 84 | self.parent.new.click() 85 | 86 | 87 | @navigator.register(UserEntity, 'Edit') 88 | class EditUser(NavigateStep): 89 | """Navigate to Edit User page 90 | 91 | Args: 92 | entity_name: name of the user 93 | """ 94 | 95 | VIEW = UserDetailsView 96 | 97 | def prerequisite(self, *args, **kwargs): 98 | return self.navigate_to(self.obj, 'All') 99 | 100 | def step(self, *args, **kwargs): 101 | entity_name = kwargs.get('entity_name') 102 | self.parent.search(entity_name) 103 | self.parent.table.row(username=entity_name)['Username'].widget.click() 104 | -------------------------------------------------------------------------------- /airgun/views/puppet_environment.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Text, TextInput, View 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import BaseLoggedInView, SatTab, SatTable, SearchableViewMixin 5 | from airgun.widgets import ActionsDropdown, MultiSelect 6 | 7 | 8 | class PuppetEnvironmentTableView(BaseLoggedInView, SearchableViewMixin): 9 | """ 10 | Basic view after clicking Configure -> Environments. 11 | In basic view, there can be seen title Puppet Environments, button 12 | Create Puppet Environment (new), button import environments 13 | and table with existing Puppet Environments 14 | """ 15 | 16 | title = Text(".//h1[contains(., 'Puppet Environments')]") 17 | new = Text(".//a[normalize-space(.)='Create Puppet Environment']") 18 | import_environments = Text( 19 | ".//span[contains(@class, 'btn')]/a[contains(@href, 'import_environments')]" 20 | ) 21 | table = SatTable( 22 | locator='.//table', 23 | column_widgets={ 24 | 'Name': Text( 25 | ".//a[starts-with(@href, '/foreman_puppet/environments/') and \ 26 | contains(@href,'/edit')]" 27 | ), 28 | 'Actions': ActionsDropdown('./div[contains(@class, "btn-group")]'), 29 | }, 30 | ) 31 | 32 | @property 33 | def is_displayed(self): 34 | return self.browser.wait_for_element(self.title, exception=False) is not None 35 | 36 | 37 | class PuppetEnvironmentImportView(BaseLoggedInView, SearchableViewMixin): 38 | """ 39 | View after clicking Configure -> Environments -> import environments with 40 | toggles New, Updated, Obsolete. Button update and cancel 41 | """ 42 | 43 | breadcrumb = BreadCrumb() 44 | new = Text(".//a[contains(@data-original-title,'new')]") 45 | updated = Text(".//a[contains(@data-original-title,'updated')]") 46 | obsolete = Text(".//a[contains(@data-original-title,'obsolete')]") 47 | update = Text(".//input[@name='commit']") 48 | cancel = Text(".//a[contains(@class, 'btn') and @href='/environments']") 49 | table = SatTable( 50 | locator='.//table', 51 | column_widgets={ 52 | 'Environment': Text('./a'), 53 | }, 54 | ) 55 | 56 | @property 57 | def is_displayed(self): 58 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 59 | return ( 60 | breadcrumb_loaded 61 | and self.breadcrumb.locations[0] == 'Environments' 62 | and self.breadcrumb.read() == 'Changed Environments' 63 | ) 64 | 65 | 66 | class PuppetEnvironmentCreateView(BaseLoggedInView): 67 | """ 68 | Details view of the page with boxes that have to be filled in to 69 | create a new puppet environment 70 | """ 71 | 72 | breadcrumb = BreadCrumb() 73 | submit = Text(".//input[@name='commit']") 74 | 75 | @property 76 | def is_displayed(self): 77 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 78 | return ( 79 | breadcrumb_loaded 80 | and self.breadcrumb.locations[0] == 'Environments' 81 | and self.breadcrumb.read() == 'Create Environment' 82 | ) 83 | 84 | @View.nested 85 | class environment(SatTab): 86 | name = TextInput(id='environment_name') 87 | 88 | @View.nested 89 | class locations(SatTab): 90 | resources = MultiSelect(id='ms-environment_location_ids') 91 | 92 | @View.nested 93 | class organizations(SatTab): 94 | resources = MultiSelect(id='ms-environment_organization_ids') 95 | -------------------------------------------------------------------------------- /airgun/entities/usergroup.py: -------------------------------------------------------------------------------- 1 | from navmazing import NavigateToSibling 2 | from widgetastic.exceptions import NoSuchElementException 3 | 4 | from airgun.entities.base import BaseEntity 5 | from airgun.navigation import NavigateStep, navigator 6 | from airgun.utils import retry_navigation 7 | from airgun.views.usergroup import ( 8 | UserGroupCreateView, 9 | UserGroupDetailsView, 10 | UserGroupsView, 11 | ) 12 | 13 | 14 | class UserGroupEntity(BaseEntity): 15 | endpoint_path = '/usergroups' 16 | 17 | def create(self, values): 18 | """Create new user group entity""" 19 | view = self.navigate_to(self, 'New') 20 | view.fill(values) 21 | view.submit.click() 22 | view.flash.assert_no_error() 23 | view.flash.dismiss() 24 | 25 | def search(self, value): 26 | """Search for user group entity""" 27 | view = self.navigate_to(self, 'All') 28 | return view.search(value) 29 | 30 | def read(self, entity_name, widget_names=None): 31 | """Read all values for created user group entity""" 32 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 33 | return view.read(widget_names=widget_names) 34 | 35 | def update(self, entity_name, values): 36 | """Update necessary values for user group""" 37 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 38 | view.fill(values) 39 | view.submit.click() 40 | view.flash.assert_no_error() 41 | view.flash.dismiss() 42 | 43 | def delete(self, entity_name): 44 | """Remove existing user group entity""" 45 | view = self.navigate_to(self, 'All') 46 | view.search(entity_name) 47 | view.table.row(name=entity_name)['Actions'].widget.click(handle_alert=True) 48 | view.flash.assert_no_error() 49 | view.flash.dismiss() 50 | 51 | def refresh_external_group(self, entity_name, external_group_name): 52 | """Refresh external group.""" 53 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 54 | view.external_groups.table.row(name=external_group_name)['Actions'].widget.click() 55 | view.flash.assert_no_error() 56 | view.flash.dismiss() 57 | 58 | 59 | @navigator.register(UserGroupEntity, 'All') 60 | class ShowAllUserGroups(NavigateStep): 61 | """Navigate to All User Groups page""" 62 | 63 | VIEW = UserGroupsView 64 | 65 | @retry_navigation 66 | def step(self, *args, **kwargs): 67 | self.view.menu.select('Administer', 'User Groups') 68 | 69 | 70 | @navigator.register(UserGroupEntity, 'New') 71 | class AddNewUserGroup(NavigateStep): 72 | """Navigate to Create User Group page""" 73 | 74 | VIEW = UserGroupCreateView 75 | 76 | prerequisite = NavigateToSibling('All') 77 | 78 | def step(self, *args, **kwargs): 79 | try: 80 | self.parent.new.click() 81 | except NoSuchElementException: 82 | self.parent.new_on_blank_page.click() 83 | 84 | 85 | @navigator.register(UserGroupEntity, 'Edit') 86 | class EditUserGroup(NavigateStep): 87 | """Navigate to Edit User Group page 88 | 89 | Args: 90 | entity_name: name of the user group 91 | """ 92 | 93 | VIEW = UserGroupDetailsView 94 | 95 | def prerequisite(self, *args, **kwargs): 96 | return self.navigate_to(self.obj, 'All') 97 | 98 | def step(self, *args, **kwargs): 99 | entity_name = kwargs.get('entity_name') 100 | self.parent.search(entity_name) 101 | self.parent.table.row(name=entity_name)['Name'].widget.click() 102 | -------------------------------------------------------------------------------- /airgun/views/subnet.py: -------------------------------------------------------------------------------- 1 | from widgetastic.widget import Table, Text, TextInput, View 2 | from widgetastic_patternfly import BreadCrumb 3 | 4 | from airgun.views.common import BaseLoggedInView, SatTab, SearchableViewMixinPF4 5 | from airgun.widgets import ( 6 | CustomParameter, 7 | FilteredDropdown, 8 | MultiSelect, 9 | RadioGroup, 10 | ) 11 | 12 | 13 | class SubnetsView(BaseLoggedInView, SearchableViewMixinPF4): 14 | title = Text('//*[(self::h1 or self::h5) and normalize-space(.)="Subnets"]') 15 | new = Text('//a[normalize-space(.)="Create Subnet"]') 16 | table = Table( 17 | './/table', 18 | column_widgets={ 19 | 'Name': Text('./a'), 20 | 'Hosts': Text('./a'), 21 | 'Actions': Text('.//a[@data-method="delete"]'), 22 | }, 23 | ) 24 | 25 | @property 26 | def is_displayed(self): 27 | return self.browser.wait_for_element(self.title, exception=False) is not None 28 | 29 | 30 | class SubnetCreateView(BaseLoggedInView): 31 | breadcrumb = BreadCrumb() 32 | submit = Text('//input[@name="commit"]') 33 | 34 | @property 35 | def is_displayed(self): 36 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 37 | return ( 38 | breadcrumb_loaded 39 | and self.breadcrumb.locations[0] == 'Subnets' 40 | and self.breadcrumb.read() == 'Create Subnet' 41 | ) 42 | 43 | @View.nested 44 | class subnet(SatTab): 45 | name = TextInput(id='subnet_name') 46 | description = TextInput(id='subnet_description') 47 | protocol = RadioGroup(locator="//div[label[contains(., 'Protocol')]]") 48 | network_address = TextInput(id='subnet_network') 49 | network_prefix = TextInput(id='subnet_cidr') 50 | network_mask = TextInput(id='subnet_mask') 51 | gateway_address = TextInput(id='subnet_gateway') 52 | primary_dns = TextInput(id='subnet_dns_primary') 53 | secondary_dns = TextInput(id='subnet_dns_secondary') 54 | ipam = FilteredDropdown(id='subnet_ipam') 55 | vlanid = TextInput(id='subnet_vlanid') 56 | mtu = TextInput(id='subnet_mtu') 57 | boot_mode = FilteredDropdown(id='subnet_boot_mode') 58 | 59 | @View.nested 60 | class remote_execution(SatTab): 61 | TAB_NAME = 'Remote Execution' 62 | capsules = MultiSelect(id='ms-subnet_remote_execution_proxy_ids') 63 | 64 | @View.nested 65 | class domains(SatTab): 66 | resources = MultiSelect(id='ms-subnet_domain_ids') 67 | 68 | @View.nested 69 | class capsules(SatTab): 70 | dhcp_capsule = FilteredDropdown(id='subnet_dhcp_id') 71 | tftp_capsule = FilteredDropdown(id='subnet_tftp_id') 72 | reverse_dns_capsule = FilteredDropdown(id='subnet_dns_id') 73 | discovery_capsule = FilteredDropdown(id='subnet_discovery_id') 74 | 75 | @View.nested 76 | class parameters(SatTab): 77 | subnet_params = CustomParameter(id='global_parameters_table') 78 | 79 | @View.nested 80 | class locations(SatTab): 81 | resources = MultiSelect(id='ms-subnet_location_ids') 82 | 83 | @View.nested 84 | class organizations(SatTab): 85 | resources = MultiSelect(id='ms-subnet_organization_ids') 86 | 87 | 88 | class SubnetEditView(SubnetCreateView): 89 | @property 90 | def is_displayed(self): 91 | breadcrumb_loaded = self.browser.wait_for_element(self.breadcrumb, exception=False) 92 | return ( 93 | breadcrumb_loaded 94 | and self.breadcrumb.locations[0] == 'Subnets' 95 | and self.breadcrumb.read().startswith('Edit ') 96 | ) 97 | -------------------------------------------------------------------------------- /airgun/entities/role.py: -------------------------------------------------------------------------------- 1 | from navmazing import NavigateToSibling 2 | 3 | from airgun.entities.base import BaseEntity 4 | from airgun.navigation import NavigateStep, navigator 5 | from airgun.utils import retry_navigation 6 | from airgun.views.role import RoleCloneView, RoleCreateView, RoleEditView, RolesView 7 | 8 | 9 | class RoleEntity(BaseEntity): 10 | endpoint_path = '/roles' 11 | 12 | def create(self, values): 13 | """Create new role""" 14 | view = self.navigate_to(self, 'New') 15 | view.fill(values) 16 | view.submit.click() 17 | view.flash.assert_no_error() 18 | view.flash.dismiss() 19 | 20 | def search(self, value): 21 | view = self.navigate_to(self, 'All') 22 | return view.search(value) 23 | 24 | def read(self, entity_name, widget_names=None): 25 | """Read role values""" 26 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 27 | return view.read(widget_names=widget_names) 28 | 29 | def update(self, entity_name, values): 30 | """Update role with provided values""" 31 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 32 | view.fill(values) 33 | view.submit.click() 34 | view.flash.assert_no_error() 35 | view.flash.dismiss() 36 | 37 | def delete(self, entity_name): 38 | """Delete role from the system""" 39 | view = self.navigate_to(self, 'All') 40 | view.search(entity_name) 41 | view.table.row(name=entity_name)['Actions'].widget.fill('Delete') 42 | self.browser.handle_alert() 43 | view.flash.assert_no_error() 44 | view.flash.dismiss() 45 | 46 | def clone(self, entity_name, values): 47 | """Clone role with entity_name with new properties values""" 48 | view = self.navigate_to(self, 'Clone', entity_name=entity_name) 49 | view.fill(values) 50 | view.submit.click() 51 | view.flash.assert_no_error() 52 | view.flash.dismiss() 53 | 54 | 55 | @navigator.register(RoleEntity, 'All') 56 | class ShowAllRoles(NavigateStep): 57 | """Navigate to All Roles page""" 58 | 59 | VIEW = RolesView 60 | 61 | @retry_navigation 62 | def step(self, *args, **kwargs): 63 | self.view.menu.select('Administer', 'Roles') 64 | 65 | 66 | @navigator.register(RoleEntity, 'New') 67 | class AddNewRole(NavigateStep): 68 | """Navigate to Create New Role page""" 69 | 70 | VIEW = RoleCreateView 71 | 72 | prerequisite = NavigateToSibling('All') 73 | 74 | def step(self, *args, **kwargs): 75 | self.parent.new.click() 76 | 77 | 78 | @navigator.register(RoleEntity, 'Edit') 79 | class EditRole(NavigateStep): 80 | """Navigate to Edit Role page 81 | 82 | Args: 83 | entity_name: name of role 84 | """ 85 | 86 | VIEW = RoleEditView 87 | 88 | def prerequisite(self, *args, **kwargs): 89 | return self.navigate_to(self.obj, 'All') 90 | 91 | def step(self, *args, **kwargs): 92 | entity_name = kwargs.get('entity_name') 93 | self.parent.search(entity_name) 94 | self.parent.table.row(name=entity_name)['Name'].widget.click() 95 | 96 | 97 | @navigator.register(RoleEntity, 'Clone') 98 | class CloneRole(NavigateStep): 99 | """Navigate to Clone Role page""" 100 | 101 | VIEW = RoleCloneView 102 | 103 | def prerequisite(self, *args, **kwargs): 104 | return self.navigate_to(self.obj, 'All') 105 | 106 | def step(self, *args, **kwargs): 107 | entity_name = kwargs.get('entity_name') 108 | self.parent.search(entity_name) 109 | self.parent.table.row(name=entity_name)['Actions'].widget.fill('Clone') 110 | -------------------------------------------------------------------------------- /airgun/entities/contentcredential.py: -------------------------------------------------------------------------------- 1 | from navmazing import NavigateToSibling 2 | 3 | from airgun.entities.base import BaseEntity 4 | from airgun.navigation import NavigateStep, navigator 5 | from airgun.utils import retry_navigation 6 | from airgun.views.contentcredential import ( 7 | ContentCredentialCreateView, 8 | ContentCredentialEditView, 9 | ContentCredentialsTableView, 10 | ) 11 | from airgun.views.product import ProductEditView 12 | 13 | 14 | class ContentCredentialEntity(BaseEntity): 15 | endpoint_path = '/content_credentials' 16 | 17 | def create(self, values): 18 | """Create new content credentials entity""" 19 | view = self.navigate_to(self, 'New') 20 | view.fill(values) 21 | view.submit.click() 22 | 23 | def delete(self, entity_name): 24 | """Delete existing content credentials entity""" 25 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 26 | view.remove.click() 27 | self.browser.handle_alert() 28 | view.flash.assert_no_error() 29 | view.flash.dismiss() 30 | 31 | def search(self, value): 32 | """search for content credentials entity""" 33 | view = self.navigate_to(self, 'All') 34 | return view.search(value) 35 | 36 | def read(self, entity_name, widget_names=None): 37 | """Read content credentials entity values""" 38 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 39 | return view.read(widget_names=widget_names) 40 | 41 | def update(self, entity_name, values): 42 | """Update content credentials entity values""" 43 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 44 | filled_values = view.fill(values) 45 | view.flash.assert_no_error() 46 | view.flash.dismiss() 47 | return filled_values 48 | 49 | def get_product_details(self, entity_name, product_name): 50 | """Get entity values for a product which associated to gpg key 51 | 52 | :param entity_name: Gpg key name 53 | :param product_name: Name of associated product 54 | """ 55 | view = self.navigate_to(self, 'Edit', entity_name=entity_name) 56 | view.products.search(product_name) 57 | view.products.table.row(name=product_name)['Name'].widget.click() 58 | product_view = ProductEditView(self.browser) 59 | return product_view.read() 60 | 61 | 62 | @navigator.register(ContentCredentialEntity, 'All') 63 | class ShowAllContentCredentials(NavigateStep): 64 | """Navigate to All Content Credentials page""" 65 | 66 | VIEW = ContentCredentialsTableView 67 | 68 | @retry_navigation 69 | def step(self, *args, **kwargs): 70 | self.view.menu.select('Content', 'Content Credentials') 71 | 72 | 73 | @navigator.register(ContentCredentialEntity, 'New') 74 | class AddNewContentCredential(NavigateStep): 75 | """Navigate to Create Content Credential page""" 76 | 77 | VIEW = ContentCredentialCreateView 78 | 79 | prerequisite = NavigateToSibling('All') 80 | 81 | def step(self, *args, **kwargs): 82 | self.parent.new.click() 83 | 84 | 85 | @navigator.register(ContentCredentialEntity, 'Edit') 86 | class EditContentCredential(NavigateStep): 87 | """Navigate to Content Credential details screen. 88 | 89 | Args: 90 | entity_name: name of content credential to edit 91 | """ 92 | 93 | VIEW = ContentCredentialEditView 94 | 95 | def prerequisite(self, *args, **kwargs): 96 | return self.navigate_to(self.obj, 'All') 97 | 98 | def step(self, *args, **kwargs): 99 | entity_name = kwargs.get('entity_name') 100 | self.parent.search(entity_name) 101 | self.parent.table.row(name=entity_name)['Name'].widget.click() 102 | --------------------------------------------------------------------------------