Project: SatelliteQE Airgun
Repository: https://github.com/SatelliteQE/airgun
-
Before committing, run the Pre-commit Hooks
- Run manually:
pre-commit run --all-files
- Run manually:
-
Follow conventional commit format when naming commits
-
For local testing, run a local install within your robottelo folder
- Within the robottelo directory:
pip install <path to airgun directory>
- Within the robottelo directory:
- Separates UI element definitions (Views) from webpage interactions (Entities)
- Uses Selenium for navigation
- Supports PatternFly 4 and PatternFly 5 UI components
- Selenium: Browser automation
- Widgetastic: Widget abstraction layer
- navmazing: Declarative navigation system
- pytest: Test framework integration
Airgun follows a three-layer architecture that separates concerns and makes UI testing more maintainable:
The top layer where tests are written. Tests use the Session object to interact with the Satellite UI without needing to know implementation details.
- Purpose: Write test logic using high-level entity methods
- Example:
session.activationkey.create({'name': 'my-key'}) - Benefits: Tests are clean, readable, and maintainable
The middle layer containing pytest functions that interact with webpages.
- Purpose: Provide reusable methods for UI interactions
- Location:
airgun/entities/(e.g.,activationkey.py,host.py) - Responsibilities:
- Navigate to appropriate pages
- Fill forms and click buttons via views
- Handle flash messages and errors
- Return structured data from pages
The bottom layer defining UI element structures using Widgetastic widgets. Views map to actual pages or components in the Satellite UI.
- Purpose: Define where elements are located on pages
- Location:
airgun/views/(e.g.,activationkey.py,host.py) - Responsibilities:
- Define widget locators (XPath, ID, CSS)
- Verify page display with
is_displayedproperty
- Entity (
entities/): Pytest functions that use navigation and views to interact with webpages - View (
views/): UI element definitions using Widgetastic widgets with locators
Entities provide business logic methods (Sometimes CRUD operations, sometimes entity specific operations, usually both):
class ActivationKeyEntity(BaseEntity):
def create(self, values):
"""Create a new activation key"""
view = self.navigate_to(self, 'New')
view.fill(values)
view.submit.click()
view.flash.assert_no_error()
def read(self, entity_name):
"""Read activation key details"""
view = self.navigate_to(self, 'Edit', entity_name=entity_name)
return view.read()Location: airgun/entities/<feature>.py
Views define UI element structure using Widgetastic widgets:
class ActivationKeyCreateView(BaseLoggedInView):
name = TextInput(id='name')
description = TextInput(id='description')
submit = Button('Submit')
@property
def is_displayed(self):
return self.name.is_displayed and self.browser.title == 'New Activation Key'Location: airgun/views/<feature>.py
Reusable UI components defined in widgets.py:
from airgun.widgets import SatTable, Search, LCESelector
class MyView(BaseLoggedInView):
searchbox = Search()
table = SatTable(locator='//table[@id="my-table"]')
lce_selector = LCESelector()Common Widgets:
SatTable: Satellite-specific table widgetSearch: Search bar with autocompleteLCESelector: Lifecycle Environment selectorContextSelector: Organization/Location switcherSatFlashMessages: Flash message handler
Navigation is declarative using navmazing:
from airgun.navigation import NavigateStep, navigator
@navigator.register(ActivationKeyEntity, 'All')
class ShowAllActivationKeys(NavigateStep):
VIEW = ActivationKeysView
def step(self, *args, **kwargs):
self.view.menu.select('Content', 'Activation Keys')
@navigator.register(ActivationKeyEntity, 'New')
class CreateNewActivationKey(NavigateStep):
VIEW = ActivationKeyCreateView
prerequisite = NavigateToSibling('All')
def step(self, *args, **kwargs):
self.parent.new.click()Key Points:
VIEW: The view class to instantiateprerequisite: Navigation dependenciesstep(): Actions to reach this page
- Standard library imports
- Third-party imports (alphabetical)
- Airgun imports (alphabetical)
- Blank line between groups
# Standard library
import logging
from datetime import datetime
# Third-party
from widgetastic.widget import Text, TextInput, View
from widgetastic_patternfly5 import Button, Table
# Airgun
from airgun.entities.base import BaseEntity
from airgun.views.common import BaseLoggedInView
from airgun.widgets import Search, SatTable| Type | Convention | Example |
|---|---|---|
| Classes | PascalCase | ActivationKeyEntity, HostCreateView |
| Functions/Methods | snake_case | create(), read(), navigate_to() |
| Constants | UPPER_SNAKE_CASE | DEFAULT_TIMEOUT, MAX_RETRIES |
| Private | Leading underscore | _helper, _navigate() |
| View Classes | End with "View" | ActivationKeyCreateView |
| Entity Classes | End with "Entity" | ActivationKeyEntity |
class MyEntity(BaseEntity):
def search(self, query):
"""Search for entities"""
view = self.navigate_to(self, 'All')
view.searchbox.search(query)
return view.table.read()
def read(self, entity_name):
"""Read entity details"""
view = self.navigate_to(self, 'Edit', entity_name=entity_name)
return view.read()
class MyListView(BaseLoggedInView):
title = Text('.//h1')
searchbox = Search()
new = Button('Create')
table = SatTable(locator='//table[@aria-label="my-table"]')
@property
def is_displayed(self):
return self.browser.wait_for_element(self.title, exception=False)class MyTabsListView(View):
@View.nested
class details_tab(PF5Tab):
TAB_NAME = 'details'
name = TextInput(id='name')
@View.nested
class content_tab(PF5Tab):
TAB_NAME = 'content'
table = SatTable(locator='//table')from wait_for import wait_for
def my_action(self):
view = self.navigate_to(self, 'All')
# Wait for element
wait_for(lambda: view.table.is_displayed, timeout=30)
# Wait for specific condition
wait_for(
lambda: len(view.table.read()) > 0,
timeout=60,
delay=2,
handle_exception=True
)
# Use browser plugin for page safety
self.browser.plugin.ensure_page_safe(timeout='10s')# XPath (most common)
Text('.//span[@class="status"]')
# ID
TextInput(id='username')
# CSS Selector
Button(locator='css:button.primary')
# Parametrized Locator
ParametrizedLocator('.//div[@data-id={@item_id}]')@navigator.register(MyEntity, 'All')
class NavigateToAll(NavigateStep):
VIEW = MyListView
def step(self):
self.view.menu.select('My Menu', 'Submenu')
@navigator.register(MyEntity, 'Edit')
class NavigateToEdit(NavigateStep):
VIEW = MyEditView
prerequisite = NavigateToSibling('All')
def step(self, entity_name):
# entity_name passed from navigate_to() call
self.parent.searchbox.search(entity_name)
self.parent.table.row(name=entity_name)['Name'].widget.click()# In entity methods
view = self.navigate_to(self, 'All')
view = self.navigate_to(self, 'Edit', entity_name='my-entity')
# Check current location
if self.navigate_to(self, 'All', _is_displayed_check=True):
# Already on the page
pass- Use
BaseLoggedInViewfor all Satellite pages - Use descriptive variable names
- Add docstrings to public methods
- Keep views and entities separate
- Prioritize readability over complexity
- Write flat code structures over nested code structures
- Use Patternfly5 when generating views/entities
- Add newly created entities to the import list in airgun/session.py and as a cached property in the Session class
- When importing from OUIA, follow this pattern: widgetastic.patternfly5.ouia import ( Button as PF5OUIAButton )
- When importing from the PF5 library, follow this pattern: widgetastic.patternfly5 import ( Button as PF5Button )
- Don't create circular imports between view files
- Don't put business logic in views - keep it in entities
- Don't use absolute XPaths - use relative or attributes
- Don't duplicate widgets - create reusable components in
widgets.py - Don't generate new entities unless asked for specifically
- Don't generate navigators automatically - only create when asked
- Don't use OUIA-Generated IDs - they're unstable and change between renders. Use stable attributes instead:
- ✅ Class names:
pf-v5-c-table,pf-v5-c-menu - ✅ ARIA labels:
aria-label="Kebab toggle" - ✅ Data attributes:
data-label="CVE ID" - ✅ Element types:
//select,//button - ❌
data-ouia-component-id="OUIA-Generated-FormSelect-default-3"
- ✅ Class names:
- Don't use negative indexing for table columns - Widgetastic doesn't support
row[-1]. Use column header text as keys instead:row['Column with row actions'] - Don't override ROOT in table column widgets unless necessary - the table infrastructure handles finding the
<td>element
Post-navigation verification ensures navigation succeeded before proceeding with test actions, eliminating timing workarounds.
How it works:
- After performing the navigation,
navigate_to()now waits for the destination'sam_i_here(which calls the view'sis_displayedmethod) to return True - Uses
wait_for(default timeout: 20 seconds) - Returns the view to the caller after the check has passed
- Raises
NavigationTriesExceededif verification fails after all retry attempts
Before (legacy behavior):
view = self.navigate_to(self, 'Edit', entity_name='foo')
# No guarantee view is ready - need manual waits
view.wait_displayed()
self.browser.plugin.ensure_page_safe()
view.table.read()
After (new behavior):
view = self.navigate_to(self, 'Edit', entity_name='foo')
# View is guaranteed to be displayed and ready
return view.table.read()
Change import and remove @retry_navigation decorators:
# Before
from airgun.navigation import NavigateStep, navigator
from airgun.utils import retry_navigation
@navigator.register(MyEntity, 'All')
class NavigateToAll(NavigateStep):
VIEW = MyListView
@retry_navigation
def step(self, *args, **kwargs):
self.view.menu.select('My Menu', 'Submenu')
# After
from airgun.navigation import NavigateStepWithWait as NavigateStep, navigator
@navigator.register(MyEntity, 'All')
class NavigateToAll(NavigateStep):
VIEW = MyListView
def step(self, *args, **kwargs):
self.view.menu.select('My Menu', 'Submenu')
Replace wait methods with property checks:
# Before
@property
def is_displayed(self):
return self.browser.wait_for_element(self.title, exception=False) is not None
# After
@property
def is_displayed(self):
return self.title.is_displayed
For multiple component checks:
# Before
@property
def is_displayed(self):
return self.table.wait_displayed() and self.clear_button.wait_displayed()
# After
@property
def is_displayed(self):
return self.table.is_displayed and self.clear_button.is_displayed
# Before
def read(self, entity_name):
view = self.navigate_to(self, 'Edit', entity_name=entity_name)
view.wait_displayed()
self.browser.plugin.ensure_page_safe()
return view.read()
# After
def read(self, entity_name):
view = self.navigate_to(self, 'Edit', entity_name=entity_name)
return view.read()
Keep waits for dynamic modal page load:
# KEEP - waiting for modal to appear after button click
view.remediate_button.click()
modal = RemediationModal(self.browser)
modal.wait_displayed(timeout=10)
modal.confirm.click()
To override the default wait timeout or number of tries for specific navigation steps:
class NavigateToMyTrickyView(NavigateStep):
VIEW = MyTrickyView
DEFAULT_TRIES = 3 # Retry up to 3 times
WAIT_TIMEOUT = 30 # Wait 30 seconds for am_i_here
def step(self, *args, **kwargs):
self.view.menu.select('Tricky', 'View')
- Documentation: https://airgun.readthedocs.io/
- Widgetastic Docs: https://widgetastic.readthedocs.io/
- navmazing Docs: https://navmazing.readthedocs.io/
- Repository: https://github.com/SatelliteQE/airgun
- Issues: https://github.com/SatelliteQE/airgun/issues
- Selenium Docs: https://www.selenium.dev/documentation/
- Widgetastic Docs: https://widgetastic.readthedocs.io/en/latest/
Last Updated: 2025-11-11
Maintainers: Sam Bible, Cole Higgins