diff --git a/airgun/entities/capsule.py b/airgun/entities/capsule.py new file mode 100644 index 000000000..ca53fe75a --- /dev/null +++ b/airgun/entities/capsule.py @@ -0,0 +1,298 @@ +from airgun.entities.base import BaseEntity +from airgun.navigation import NavigateStep, navigator +from airgun.utils import retry_navigation +from airgun.views.capsule import ( + CapsuleDetailsView, + CapsulesView, + CreateCapsuleView, + EditCapsuleView, +) + + +class CapsuleEntity(BaseEntity): + endpoint_path = '/smart_proxies' + + def get_operation_status(self, view): + """ + This functions accepts view and returns status of operation (success/error) + or None if no message is displayed. + + Args: + view (View): View of page where operation was performed + Returns: + refresh_status (str): Status of operation (success/error) or None + """ + + if view.success_message.is_displayed: + refresh_status = view.success_message.read() + elif view.error_message.is_displayed: + refresh_status = view.error_message.read() + else: + refresh_status = None + return refresh_status + + def read(self, capsule_name): + """ + Read Capsule details from Capsules page + + Args: + capsule_name (str): Name of capsule to be read + """ + + view = self.navigate_to(self, 'Capsules') + view.searchbox.search(f'name="{capsule_name}"') + return view.read() + + def read_details(self, capsule_name): + """ + Read Capsule details from Capsule details page + + Args: + capsule_name (str): Name of capsule to be read + """ + + view = self.navigate_to(self, 'Capsules') + view.searchbox.search(f'name="{capsule_name}"') + view.table.row(name=capsule_name)['Name'].click() + view = CapsuleDetailsView(self.browser) + return view.read() + + def read_all(self): + """Read all values from Capsules page""" + + view = self.navigate_to(self, 'Capsules') + return view.read() + + def view_documentation(self): + """Opens Capsule documentation page""" + + view = self.navigate_to(self, 'Capsules') + view.documentation.click() + + def create(self, values): + """ + Function that creates capsule according to the parameters + + Args: + values (dict): Dictionary of values to be filled in + Structure of this dict should be: + { + 'capsule.name': 'test', + 'capsule.url': 'test', + 'capsule.acs_http_proxy': 'test', + 'locations.resources.assigned': ['Default Location', 'test'], + 'organizations.resources.assigned': ['Default Organization', 'test'], + } + """ + + view = self.navigate_to(self, 'Capsules') + view.create_capsule.click() + view = CreateCapsuleView(self.browser) + view.fill(values) + view.submit.click() + view = CapsulesView(self.browser) + + def edit( + self, + capsule_name_to_edit, + new_capsule_name=None, + new_capsule_url=None, + download_policy=None, + acs_http_proxy=None, + add_all_lces=False, + remove_all_lces=False, + assigned_lces=None, + add_lces=None, + add_all_locations=False, + remove_all_locations=False, + assigned_locations=None, + add_locations=None, + add_all_organizations=False, + add_organizations=None, + remove_all_organizations=False, + assigned_organizations=None, + ): + """ + Function that edits capsule according to the parameters + + Args: + capsule_name (str): Name of capsule to be edited + """ + + view = self.navigate_to(self, 'Capsules') + view.search(f'name="{capsule_name_to_edit}"') + view.table.row(name=capsule_name_to_edit)['Actions'].widget.fill('Edit') + view = EditCapsuleView(self.browser) + + if new_capsule_name: + view.capsule.name.fill(new_capsule_name) + + if new_capsule_url: + view.capsule.url.fill(new_capsule_url) + + if download_policy: + view.capsule.download_policy.fill(download_policy) + + if acs_http_proxy: + view.capsule.acs_http_proxy.fill(acs_http_proxy) + + if acs_http_proxy is None and view.capsule.remove_proxy_selection.is_displayed: + view.capsule.remove_proxy_selection.click() + + if add_all_lces: + view.lifecycle_enviroments.resources.add_all() + + if remove_all_lces: + view.lifecycle_enviroments.resources.remove_all() + + if assigned_lces: + view.lifecycle_enviroments.resources.remove_all() + view.lifecycle_enviroments.resources.fill({'assigned': assigned_lces}) + + if add_lces: + view.lifecycle_enviroments.resources.fill({'assigned': add_lces}) + + if add_all_locations: + view.locations.resources.add_all() + + if remove_all_locations: + view.locations.resources.remove_all() + + if assigned_locations: + view.locations.resources.remove_all() + view.locations.resources.fill({'assigned': assigned_locations}) + + if add_locations: + view.locations.resources.fill({'assigned': add_locations}) + + if add_all_organizations: + view.organizations.resources.add_all() + + if remove_all_organizations: + view.organizations.resources.remove_all() + + if assigned_organizations: + view.organizations.resources.remove_all() + view.organizations.resources.fill({'assigned': assigned_organizations}) + + if add_organizations: + view.organizations.resources.fill({'assigned': add_organizations}) + + view.submit.click() + view = CapsulesView(self.browser) + view.search('') + + def refresh(self, capsule_name): + """ + Function that refreshes given capsule + + Args: + capsule_name (str): Name of capsule to be refreshed + Returns: + refresh_status (str): Status of refresh action (success/error) or None + """ + + view = self.navigate_to(self, 'Capsules') + view.table.row(name=capsule_name)['Actions'].widget.fill('Refresh') + + return self.get_operation_status(view) + + def expire_logs(self, capsule_name): + """ + Function that expires logs of given capsule + + Args: + capsule_name (str): Name of capsule we want logs to expire + """ + + view = self.navigate_to(self, 'Capsules') + view.table.row(name=capsule_name)['Actions'].widget.fill('Expire logs') + + def delete(self, capsule_name): + """ + Delete given capsule + + Args: + capsule_name (str): Name of capsule to be deleted + Returns: + refresh_status (str): Status of delete action (success/error) or None + """ + + view = self.navigate_to(self, 'Capsules') + view.table.row(name=capsule_name)['Actions'].widget.fill('Delete') + if view.confirm_deletion.is_displayed: + view.confirm_deletion.confirm() + + return self.get_operation_status(view) + + def sync(self, capsule_name, sync_type): + """ + General function for syncing capsule + + Args: + capsule_name (str): Name of capsule to be synced + sync_type (str): Type of sync to be performed + """ + + view = self.navigate_to(self, 'Capsules') + view.searchbox.search(f'name="{capsule_name}"') + view.table.row(name=capsule_name)['Name'].click() + view = CapsuleDetailsView(self.browser) + if sync_type == 'Optimized Sync': + view.overview.synchronize_action_drop.fill( + view.overview.synchronize_action_drop.items[0] + ) + elif sync_type == 'Complete Sync': + view.overview.synchronize_action_drop.fill( + view.overview.synchronize_action_drop.items[1] + ) + elif sync_type == 'Reclaim Space': + # Workauround for for selecting from ActionDropdown by value which contains double quotes + view.overview.synchronize_action_drop.open() + self.browser.element('.//ul/li/a[@ng-click="reclaimSpace()"]', parent=self).click() + + def optimized_sync(self, capsule_name): + """ + Function that performs optimized sync of given capsule + + Args: + capsule_name (str): Name of capsule to be synced + """ + + self.sync(capsule_name, 'Optimized Sync') + + def complete_sync(self, capsule_name): + """ + Function that performs complete sync of given capsule + + Args: + capsule_name (str): Name of capsule to be synced + """ + + self.sync(capsule_name, 'Complete Sync') + + def refresh_lce_counts(self, capsule_name, lce_name): + """ + Function that refreshes LCE counts of given capsule + + Args: + capsule_name (str): Name of capsule to be refreshed + """ + + view = self.navigate_to(self, 'Capsules') + view.table.row(name=capsule_name)['Name'].click() + view = CapsuleDetailsView(self.browser) + view.content.top_content_table.row(Environment=lce_name)[3].widget.item_select( + 'Refresh counts' + ) + + +@navigator.register(CapsuleEntity, 'Capsules') +class OpenAcsPage(NavigateStep): + """Navigate to the Capsules page""" + + VIEW = CapsulesView + + @retry_navigation + def step(self, *args, **kwargs): + self.view.menu.select('Infrastructure', 'Capsules') diff --git a/airgun/session.py b/airgun/session.py index 89725d46e..59f608b9a 100644 --- a/airgun/session.py +++ b/airgun/session.py @@ -17,6 +17,7 @@ from airgun.entities.architecture import ArchitectureEntity from airgun.entities.audit import AuditEntity from airgun.entities.bookmark import BookmarkEntity +from airgun.entities.capsule import CapsuleEntity from airgun.entities.cloud_insights import CloudInsightsEntity from airgun.entities.cloud_inventory import CloudInventoryEntity from airgun.entities.computeprofile import ComputeProfileEntity @@ -359,6 +360,11 @@ def bookmark(self): """Instance of Bookmark entity.""" return self._open(BookmarkEntity) + @cached_property + def capsule(self): + """Instance of Capsule entity.""" + return self._open(CapsuleEntity) + @cached_property def eol_banner(self): """Instance of Bookmark entity.""" diff --git a/airgun/views/capsule.py b/airgun/views/capsule.py new file mode 100644 index 000000000..07694d435 --- /dev/null +++ b/airgun/views/capsule.py @@ -0,0 +1,270 @@ +import re + +from widgetastic.widget import ( + Select, + Text, + TextInput, + View, +) +from widgetastic_patternfly import BreadCrumb, Button +from widgetastic_patternfly4 import ( + Dropdown, + Pagination, +) +from widgetastic_patternfly4.ouia import ( + Button as OUIAButton, + ExpandableTable, +) + +from airgun.views.common import ( + BaseLoggedInView, + SatTab, + SearchableViewMixinPF4, +) +from airgun.widgets import ( + ActionsDropdown, + FilteredDropdown, + ItemsList, + MultiSelect, + Pf4ConfirmationDialog, + SatTable, +) + + +class DeleteCapsuleConfirmationDialog(Pf4ConfirmationDialog): + confirm_dialog = OUIAButton('btn-modal-confirm') + cancel_dialog = OUIAButton('btn-modal-cancel') + + +class CreateCapsuleView(BaseLoggedInView): + """Class that describes the Create Capsule page""" + + breadcrumb = BreadCrumb() + submit = Text('//input[@name="commit"]') + cancel = Text('//a[contains(@href, "smart_proxies")]') + + @View.nested + class capsule(SatTab): + name = TextInput(locator='//input[@id="smart_proxy_name"]') + url = TextInput(locator='//input[@id="smart_proxy_url"]') + acs_http_proxy = FilteredDropdown(id='s2id_smart_proxy_http_proxy_id') + remove_proxy_selection = Text(locator='//*[@id="s2id_smart_proxy_http_proxy_id"]/a/abbr') + + @View.nested + class locations(SatTab): + resources = MultiSelect(id='ms-smart_proxy_location_ids') + + @View.nested + class organizations(SatTab): + resources = MultiSelect(id='ms-smart_proxy_organization_ids') + + @property + def is_displayed(self): + return self.submit.is_displayed + + +class EditCapsuleView(CreateCapsuleView): + @View.nested + class capsule(SatTab): + name = TextInput(locator='//input[@id="smart_proxy_name"]') + url = TextInput(locator='//input[@id="smart_proxy_url"]') + download_policy = FilteredDropdown(id='s2id_smart_proxy_download_policy') + acs_http_proxy = FilteredDropdown(id='s2id_smart_proxy_http_proxy_id') + remove_proxy_selection = Text(locator='//*[@id="s2id_smart_proxy_http_proxy_id"]/a/abbr') + + @View.nested + class lifecycle_enviroments(SatTab): + TAB_NAME = 'Lifecycle Environments' + resources = MultiSelect(id='ms-smart_proxy_lifecycle_environment_ids') + + +class CapsuleDetailsView(BaseLoggedInView): + """Class that describes the Capsule Details page""" + + breadcrumb = BreadCrumb() + + actions = ActionsDropdown('./div[a[contains(@data-toggle, "dropdown")]]') + edit_capsule = Text('//a[normalize-space(.)="Edit"]') + delete_capsule = Text('//a[normalize-space(.)="Delete"]') + + success_message = Text('//div[contains(@aria-label, "Success Alert")]') + error_message = Text('//div[contains(@aria-label, "Danger Alert")]') + confirm_deletion = DeleteCapsuleConfirmationDialog() + + @View.nested + class overview(SatTab): + TAB_NAME = 'Overview' + + reclaim_space_button = Button('Reclaim Space') + + url = Text('.//div[preceding-sibling::div[contains(., "URL")]]') + version = Text('.//span[@class="proxy-version"]') + active_features = Text('.//div[contains(., "Active features")]/ancestor::div[@class="row"]') + refresh_features = Button('Refresh features') + hosts_managed = Text('.//div[preceding-sibling::div[contains(., "Hosts managed")]]') + failed_fetaures_info = Text('//div[@id="failed-modules"]') + log_messages_info = Text( + '//a[contains(@href, "#logs") and contains(@data-toggle, "tooltip")][1]' + ) + error_messages_info = Text( + '//a[contains(@data-original-title, "error") or contains(@title, "error")]' + ) + active_features_info = Text('//h2[contains(@data-toggle, "tooltip")]') + + last_sync = Text('.//div[span[contains(text(), "Last sync:")]]') + synchronize_action_drop = ActionsDropdown( + '//div[contains(@class, "dropdown") and .//button[normalize-space(.)="Synchronize"]]' + ) + storage_info = Text('//div[contains(@class, "progress-bar")]/span[1]') + + @View.nested + class services(SatTab): + TAB_NAME = 'Services' + container_gateway_version = Text( + '//div[contains(., "Container_Gateway")]/following-sibling::div[contains(., "Version")]/div[@class="col-md-8"][1]' + ) + + dynflow_version = Text( + '//div[contains(., "Dynflow")]/following-sibling::div[contains(., "Version")]/div[@class="col-md-8"][1]' + ) + + content_version = Text( + '//div[contains(., "Content")]/following-sibling::div[contains(., "Version")]/div[@class="col-md-8"][1]' + ) + content_supportted_content_types = Text( + '//div[contains(., "Content")]/div[@class="col-md-8"]/ul' + ) + + registration_version = Text( + '//div[contains(., "Registration")]/following-sibling::div[contains(., "Version")]/div[@class="col-md-8"][1]' + ) + + script_version = Text( + '//div[contains(., "Script")]/following-sibling::div[contains(., "Version")]/div[@class="col-md-8"][1]' + ) + + templates_version = Text( + '//div[contains(., "Templates")]/following-sibling::div[contains(., "Version")]/div[@class="col-md-8"][1]' + ) + + @View.nested + class logs(SatTab): + TAB_NAME = 'Logs' + + search_bar = TextInput(locator='//input[@aria-controls="table-proxy-status-logs"]') + filter_by_level = Select(locator='//select[@id="logs-filter"]') + refresh_button = Text( + locator='//a[normalize-space(.)="Refresh" and contains(@data-url,"expire_logs")]' + ) + + table = SatTable( + './/table', + column_widgets={ + 'Time': Text('./td[1]'), + 'Level': Text('./td[2]'), + 'Message': Text('./td[3]'), + }, + ) + + pagination = Pagination() + + @View.nested + class content(SatTab): + TAB_NAME = 'Content' + + top_content_table = ExpandableTable( + component_id='capsule-content-table', + column_widgets={ + 0: Button(locator='./button[@aria-label="Details"]'), + 'Environment': Text('./a'), + 'Last sync': Text('./span[contains(@class, "pf-c-label ")]'), + 3: Dropdown(locator='.//div[contains(@class, "pf-c-dropdown")]'), + }, + ) + + mid_content_table = ExpandableTable( + component_id='expandable-content-views', + column_widgets={ + 'cv_info_list': ItemsList(locator='//ul'), + }, + ) + + expanded_repo_details = ItemsList( + locator='(//ul[@aria-label="Expanded repository details"])' + ) + + def read(self): + """Reads content table and returns its content""" + read_top_content = self.top_content_table.read() + lce_names = [] + result = {} + lce_names.extend(row['Environment'] for row in read_top_content) + + for lce in lce_names: + self.top_content_table.row(Environment=lce)[0].click() + mid_content_read = self.mid_content_table.read() + cv_names = [] + cv_names.extend(row['Content view'] for row in mid_content_read) + + result[lce] = { + 'top_row_content': self.top_content_table.row(Environment=lce).read(), + } + + for i, cv in enumerate(cv_names): + self.mid_content_table.row(content_view=cv)[0].click() + self.expanded_repo_details.locator += f'[{i+1}]' + result[lce][cv] = { + 'mid_row_content': self.mid_content_table.row(content_view=cv).read(), + 'expanded_repo_details': [ + col.split('\n') for col in self.expanded_repo_details.read() + ], + } + + # Following code reads html svg tag and gets color of status icon + # and assigns bool according to that + svg_status_icon = self.browser.get_attribute( + 'outerHTML', self.mid_content_table.row(content_view=cv)[4] + ) + color = re.search('color: (.*?);', svg_status_icon).group(1) + result[lce][cv]['mid_row_content']['Synced'] = ( + True if color == 'green' else False if color == 'red' else None + ) + + self.expanded_repo_details.locator = '['.join( + self.expanded_repo_details.locator.split('[')[:-1] + ) + + self.mid_content_table.row(content_view=cv)[0].click() + + self.top_content_table.row(Environment=lce)[0].click() + + return result + + +class CapsulesView(BaseLoggedInView, SearchableViewMixinPF4): + """Class that describes the Capsule Details page""" + + title = Text('//h1[normalize-space(.)="Capsules"]') + create_capsule = Text('//a[contains(@class, "btn")][contains(@href, "smart_proxies/new")]') + documentation = Text('//a[contains(@class, "btn")][contains(@href, "manual")]') + success_message = Text('//div[contains(@aria-label, "Success Alert")]') + error_message = Text('//div[contains(@aria-label, "Danger Alert")]') + confirm_deletion = DeleteCapsuleConfirmationDialog() + + table = SatTable( + './/table', + column_widgets={ + 'Name': Text('./a[contains(@href, "smart_proxies")]'), + 'Locations': Text('./td[3]'), + 'Organizations': Text('./td[4]'), + 'Features': Text('./td[4]'), + 'Status': Text('./td[5]'), + 'Actions': ActionsDropdown('./div[contains(@class, "btn-group")]'), + }, + ) + + pagination = Pagination() + + @property + def is_displayed(self): + return self.browser.wait_for_element(self.title, exception=False) is not None diff --git a/airgun/widgets.py b/airgun/widgets.py index 3b4525013..38b216c78 100644 --- a/airgun/widgets.py +++ b/airgun/widgets.py @@ -446,6 +446,9 @@ class MultiSelect(GenericLocatorWidget): unassigned = ItemsList("./div[@class='ms-selectable']/ul") assigned = ItemsList("./div[@class='ms-selection']/ul") + add_all_button = Text(locator='.//a[contains(@class,"ms-select-all")]') + remove_all_button = Text(locator='.//a[contains(@class,"ms-deselect-all")]') + def __init__(self, parent, locator=None, id=None, logger=None): """Supports initialization via ``locator=`` or ``id=``""" if locator and id or not locator and not id: @@ -484,6 +487,14 @@ def read(self): 'assigned': self.assigned.read(), } + def add_all(self): + """Function adds all from left item select.""" + self.add_all_button.click() + + def remove_all(self): + """Function removes all from right item select.""" + self.remove_all_button.click() + class PF4MultiSelect(GenericLocatorWidget): """Typical two-pane multiselect widget. Allows to move items from