diff options
10 files changed, 483 insertions, 236 deletions
diff --git a/horizon/test/webdriver.py b/horizon/test/webdriver.py index 3edb71dcb..824bf02e4 100644 --- a/horizon/test/webdriver.py +++ b/horizon/test/webdriver.py @@ -37,7 +37,7 @@ class WrapperFindOverride(object): """Mixin for overriding find_element methods.""" def find_element(self, by=by.By.ID, value=None): - repeat = range(2) + repeat = range(10) for i in repeat: try: web_el = super().find_element(by, value) @@ -48,7 +48,7 @@ class WrapperFindOverride(object): self) def find_elements(self, by=by.By.ID, value=None): - repeat = range(2) + repeat = range(10) for i in repeat: try: web_els = super().find_elements(by, value) diff --git a/openstack_dashboard/static/app/core/images/steps/create-image/create-image.html b/openstack_dashboard/static/app/core/images/steps/create-image/create-image.html index 26aa31034..71f70ffbe 100644 --- a/openstack_dashboard/static/app/core/images/steps/create-image/create-image.html +++ b/openstack_dashboard/static/app/core/images/steps/create-image/create-image.html @@ -76,15 +76,9 @@ <label class="control-label" for="imageForm-image_url"> <translate>File</translate><span class="hz-icon-required fa fa-asterisk"></span> </label> - <div class="input-group" ng-hide="ctrl.uploadProgress > -1"> - <span class="input-group-btn"> - <button class="btn btn-primary" ng-model="image_file" - ngf-select="ctrl.prepareUpload(image_file)" - name="image_file" ng-required="true" - ng-disabled="viewModel.isSubmitting" - id="imageForm-image_file" translate>Browse...</button> - </span> - <input type="text" class="form-control" readonly ng-model="image_file.name"> + <div ng-hide="ctrl.uploadProgress > -1"> + <input type="file" ng-model="image_file" ngf-select="ctrl.prepareUpload(image_file)" + name="image_file" ng-required="true" ng-disabled="viewModel.isSubmitting"> </div> <div ng-hide="ctrl.uploadProgress < 0" class="progress-text"> <uib-progressbar value="ctrl.uploadProgress"></uib-progressbar> @@ -239,7 +233,7 @@ <translate>Visibility</translate> </label> <div class="form-field"> - <div class="btn-group"> + <div class="btn-group" name="visibility"> <label class="btn btn-default" ng-repeat="option in ctrl.imageVisibilityOptions" ng-model="ctrl.image.visibility" @@ -254,7 +248,7 @@ <translate>Protected</translate> </label> <div class="form-field"> - <div class="btn-group"> + <div class="btn-group" name="protected"> <label class="btn btn-default" ng-repeat="option in ctrl.imageProtectedOptions" ng-model="ctrl.image.protected" diff --git a/openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.html b/openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.html index ce75e8e2e..46d493227 100644 --- a/openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.html +++ b/openstack_dashboard/static/app/core/images/steps/edit-image/edit-image.html @@ -126,7 +126,7 @@ <div class="form-group"> <label class="control-label required" translate>Visibility</label> <div class="form-field"> - <div class="btn-group"> + <div class="btn-group" name="visibility"> <label class="btn btn-default" ng-repeat="option in ctrl.imageVisibilityOptions" ng-model="ctrl.image.visibility" @@ -139,7 +139,7 @@ <div class="form-group"> <label class="control-label required" translate>Protected</label> <div class="form-field"> - <div class="btn-group"> + <div class="btn-group" name="protected"> <label class="btn btn-default" ng-repeat="option in ctrl.imageProtectedOptions" ng-model="ctrl.image.protected" diff --git a/openstack_dashboard/test/integration_tests/config.py b/openstack_dashboard/test/integration_tests/config.py index fa90b1152..475bf098b 100644 --- a/openstack_dashboard/test/integration_tests/config.py +++ b/openstack_dashboard/test/integration_tests/config.py @@ -75,11 +75,11 @@ ImageGroup = [ default='angular', help='type/version of images panel'), cfg.StrOpt('http_image', - default='http://download.cirros-cloud.net/0.3.1/' - 'cirros-0.3.1-x86_64-uec.tar.gz', + default='http://download.cirros-cloud.net/0.5.2/' + 'cirros-0.5.2-x86_64-uec.tar.gz', help='http accessible image'), cfg.ListOpt('images_list', - default=['cirros-0.3.5-x86_64-disk'], + default=['cirros-0.5.2-x86_64-disk'], help='default list of images') ] diff --git a/openstack_dashboard/test/integration_tests/horizon.conf b/openstack_dashboard/test/integration_tests/horizon.conf index 1b0301e6d..52db2a69c 100644 --- a/openstack_dashboard/test/integration_tests/horizon.conf +++ b/openstack_dashboard/test/integration_tests/horizon.conf @@ -43,8 +43,9 @@ panel_type=legacy [image] # http accessible image (string value) panel_type=angular -http_image=http://download.cirros-cloud.net/0.3.1/cirros-0.3.1-x86_64-uec.tar.gz -images_list=cirros-0.3.5-x86_64-disk +http_image=http://download.cirros-cloud.net/0.5.2/cirros-0.5.2-x86_64-uec.tar.gz +images_list=cirros-0.5.2-x86_64-disk + [identity] # Username to use for non-admin API requests. (string value) diff --git a/openstack_dashboard/test/integration_tests/pages/project/compute/imagespage.py b/openstack_dashboard/test/integration_tests/pages/project/compute/imagespage.py index 304d506d8..117c64b5c 100644 --- a/openstack_dashboard/test/integration_tests/pages/project/compute/imagespage.py +++ b/openstack_dashboard/test/integration_tests/pages/project/compute/imagespage.py @@ -13,120 +13,133 @@ from selenium.webdriver.common import by from openstack_dashboard.test.integration_tests.pages import basepage from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import menus from openstack_dashboard.test.integration_tests.regions import tables from openstack_dashboard.test.integration_tests.pages.project.compute.\ instancespage import InstancesPage -from openstack_dashboard.test.integration_tests.pages.project.volumes.\ - volumespage import VolumesPage # TODO(bpokorny): Set the default source back to 'url' once Glance removes # the show_multiple_locations option, and if the default devstack policies # allow setting locations. DEFAULT_IMAGE_SOURCE = 'file' -DEFAULT_IMAGE_FORMAT = 'qcow2' +DEFAULT_IMAGE_FORMAT = 'string:raw' DEFAULT_ACCESSIBILITY = False DEFAULT_PROTECTION = False -IMAGES_TABLE_NAME_COLUMN = 'name' -IMAGES_TABLE_STATUS_COLUMN = 'status' -IMAGES_TABLE_FORMAT_COLUMN = 'disk_format' +IMAGES_TABLE_NAME_COLUMN = 'Name' +IMAGES_TABLE_STATUS_COLUMN = 'Status' +IMAGES_TABLE_FORMAT_COLUMN = 'Disk Format' -class ImagesTable(tables.TableRegion): +class ImagesTable(tables.TableRegionNG): name = "images" CREATE_IMAGE_FORM_FIELDS = ( - "name", "description", "image_file", "kernel", "ramdisk", - "disk_format", "architecture", "min_disk", "min_ram", - "is_public", "protected" + "name", "description", "image_file", "kernel", "ramdisk", "format", + "architecture", "min_disk", "min_ram", "visibility", "protected" ) CREATE_VOLUME_FROM_IMAGE_FORM_FIELDS = ( - "name", "description", "image_source", - "type", "size", "availability_zone") - - LAUNCH_INSTANCE_FROM_FIELDS = (( - "availability_zone", "name", "flavor", - "count", "source_type", "instance_snapshot_id", - "volume_id", "volume_snapshot_id", "image_id", "volume_size", - "vol_delete_on_instance_delete"), - ("keypair", "groups"), - ("script_source", "script_upload", "script_data"), - ("disk_config", "config_drive") + "name", "description", + "volume_size", + "availability_zone") + + LAUNCH_INSTANCE_FORM_FIELDS = ( + ("name", "count", "availability_zone"), + ("boot_source_type", "volume_size"), + { + 'flavor': menus.InstanceFlavorMenuRegion + }, + { + 'network': menus.InstanceAvailableResourceMenuRegion + }, ) EDIT_IMAGE_FORM_FIELDS = ( - "name", "description", "disk_format", "min_disk", - "min_ram", "public", "protected" + "name", "description", "format", "min_disk", + "min_ram", "visibility", "protected" ) - @tables.bind_table_action('create') + @tables.bind_table_action_ng('Create Image') def create_image(self, create_button): create_button.click() - return forms.FormRegion(self.driver, self.conf, - field_mappings=self.CREATE_IMAGE_FORM_FIELDS) + return forms.FormRegionNG(self.driver, self.conf, + field_mappings=self.CREATE_IMAGE_FORM_FIELDS) - @tables.bind_table_action('delete') + @tables.bind_table_action_ng('Delete Images') def delete_image(self, delete_button): delete_button.click() return forms.BaseFormRegion(self.driver, self.conf) - @tables.bind_row_action('create_volume_from_image') - def create_volume(self, create_volume, row): - create_volume.click() - return forms.FormRegion( - self.driver, self.conf, - field_mappings=self.CREATE_VOLUME_FROM_IMAGE_FORM_FIELDS) - - @tables.bind_row_action('launch_image') - def launch_instance(self, launch_instance, row): - launch_instance.click() - return forms.TabbedFormRegion( - self.driver, self.conf, - field_mappings=self.LAUNCH_INSTANCE_FROM_FIELDS) - - @tables.bind_row_action('update_metadata') - def update_metadata(self, metadata_button, row): - metadata_button.click() - return forms.MetadataFormRegion(self.driver, self.conf) - - @tables.bind_row_action('delete') + @tables.bind_row_action_ng('Delete Image') def delete_image_via_row_action(self, delete_button, row): delete_button.click() return forms.BaseFormRegion(self.driver, self.conf) - @tables.bind_row_action('edit') + @tables.bind_row_action_ng('Edit Image') def edit_image(self, edit_button, row): edit_button.click() - return forms.FormRegion(self.driver, self.conf, - field_mappings=self.EDIT_IMAGE_FORM_FIELDS) + return forms.FormRegionNG(self.driver, self.conf, + field_mappings=self.EDIT_IMAGE_FORM_FIELDS) + + @tables.bind_row_action_ng('Update Metadata') + def update_metadata(self, metadata_button, row): + metadata_button.click() + return forms.MetadataFormRegion(self.driver, self.conf) - @tables.bind_row_anchor_column(IMAGES_TABLE_NAME_COLUMN) + @tables.bind_row_anchor_column_ng(IMAGES_TABLE_NAME_COLUMN) def go_to_image_description_page(self, row_link, row): row_link.click() return forms.ItemTextDescription(self.driver, self.conf) + @tables.bind_row_action_ng('Create Volume') + def create_volume(self, create_volume, row): + create_volume.click() + return forms.FormRegion( + self.driver, self.conf, + field_mappings=self.CREATE_VOLUME_FROM_IMAGE_FORM_FIELDS) + + @tables.bind_row_action_ng('Launch') + def launch_instance(self, launch_instance, row): + launch_instance.click() + return forms.WizardFormRegion( + self.driver, self.conf, self.LAUNCH_INSTANCE_FORM_FIELDS) + class ImagesPage(basepage.BaseNavigationPage): + _resource_page_header_locator = (by.By.CSS_SELECTOR, + 'hz-resource-panel hz-page-header h1') + _default_form_locator = (by.By.CSS_SELECTOR, 'div.modal-dialog') + _search_field_locator = (by.By.CSS_SELECTOR, + 'magic-search.form-control input.search-input') + _search_button_locator = (by.By.CSS_SELECTOR, + 'hz-magic-search-bar span.fa-search') + _search_option_locator = (by.By.CSS_SELECTOR, + 'magic-search.form-control span.search-entry') def __init__(self, driver, conf): super().__init__(driver, conf) self._page_title = "Images" - def _get_row_with_image_name(self, name): - return self.images_table.get_row(IMAGES_TABLE_NAME_COLUMN, name) + @property + def header(self): + return self._get_element(*self._resource_page_header_locator) @property def images_table(self): return ImagesTable(self.driver, self.conf) + def wizard_getter(self): + return self._get_element(*self._default_form_locator) + + def _get_row_with_image_name(self, name): + return self.images_table.get_row(IMAGES_TABLE_NAME_COLUMN, name) + def create_image(self, name, description=None, image_source_type=DEFAULT_IMAGE_SOURCE, location=None, image_file=None, - image_format=DEFAULT_IMAGE_FORMAT, - is_public=DEFAULT_ACCESSIBILITY, - is_protected=DEFAULT_PROTECTION): + image_format=DEFAULT_IMAGE_FORMAT): create_image_form = self.images_table.create_image() create_image_form.name.text = name if description is not None: @@ -142,41 +155,27 @@ class ImagesPage(basepage.BaseNavigationPage): create_image_form.image_url.text = location else: create_image_form.image_file.choose(image_file) - create_image_form.disk_format.value = image_format - if is_public: - create_image_form.is_public.mark() - if is_protected: - create_image_form.protected.mark() + create_image_form.format.value = image_format create_image_form.submit() + self.wait_till_element_disappears(self.wizard_getter) def delete_image(self, name): row = self._get_row_with_image_name(name) row.mark() confirm_delete_images_form = self.images_table.delete_image() confirm_delete_images_form.submit() + self.wait_till_spinner_disappears() - def add_custom_metadata(self, name, metadata): - row = self._get_row_with_image_name(name) - update_metadata_form = self.images_table.update_metadata(row) - for field_name, value in metadata.items(): - update_metadata_form.add_custom_field(field_name, value) - update_metadata_form.submit() - - def check_image_details(self, name, dict_with_details): - row = self._get_row_with_image_name(name) - matches = [] - description_page = self.images_table.go_to_image_description_page(row) - content = description_page.get_content() - - for name, value in content.items(): - if name in dict_with_details: - if dict_with_details[name] in value: - matches.append(True) - return matches + def delete_images(self, images_names): + for image_name in images_names: + self._get_row_with_image_name(image_name).mark() + confirm_delete_images_form = self.images_table.delete_image() + confirm_delete_images_form.submit() + self.wait_till_spinner_disappears() def edit_image(self, name, new_name=None, description=None, min_disk=None, min_ram=None, - public=None, protected=None): + visibility=None, protected=None): row = self._get_row_with_image_name(name) confirm_edit_images_form = self.images_table.edit_image(row) @@ -192,15 +191,17 @@ class ImagesPage(basepage.BaseNavigationPage): if min_ram is not None: confirm_edit_images_form.min_ram.value = min_ram - if public is True: - confirm_edit_images_form.public.mark() - elif public is False: - confirm_edit_images_form.public.unmark() + if visibility is not None: + if visibility is True: + confirm_edit_images_form.visibility.pick('Shared') + elif visibility is False: + confirm_edit_images_form.visibility.pick('Private') - if protected is True: - confirm_edit_images_form.protected.mark() - elif protected is False: - confirm_edit_images_form.protected.unmark() + if protected is not None: + if protected is True: + confirm_edit_images_form.protected.pick('Yes') + elif protected is False: + confirm_edit_images_form.protected.pick('No') confirm_edit_images_form.submit() @@ -209,6 +210,25 @@ class ImagesPage(basepage.BaseNavigationPage): delete_image_form = self.images_table.delete_image_via_row_action(row) delete_image_form.submit() + def add_custom_metadata(self, name, metadata): + row = self._get_row_with_image_name(name) + update_metadata_form = self.images_table.update_metadata(row) + for field_name, value in metadata.items(): + update_metadata_form.add_custom_field(field_name, value) + update_metadata_form.submit() + + def check_image_details(self, name, dict_with_details): + row = self._get_row_with_image_name(name) + matches = [] + description_page = self.images_table.go_to_image_description_page(row) + content = description_page.get_content() + + for name, value in content.items(): + if name in dict_with_details: + if dict_with_details[name] in value: + matches.append(True) + return matches + def is_image_present(self, name): return bool(self._get_row_with_image_name(name)) @@ -222,10 +242,27 @@ class ImagesPage(basepage.BaseNavigationPage): def wait_until_image_active(self, name): self._wait_until(lambda x: self.is_image_active(name)) + def wait_until_image_present(self, name): + self._wait_until(lambda x: self.is_image_present(name)) + def get_image_format(self, name): row = self._get_row_with_image_name(name) return row.cells[IMAGES_TABLE_FORMAT_COLUMN].text + def filter(self, value): + self._set_search_field(value) + self._click_search_btn() + self.driver.implicitly_wait(5) + + def _set_search_field(self, value): + srch_field = self._get_element(*self._search_field_locator) + srch_field.clear() + srch_field.send_keys(value) + + def _click_search_btn(self): + btn = self._get_element(*self._search_button_locator) + btn.click() + def create_volume_from_image(self, name, volume_name=None, description=None, volume_size=None): @@ -236,32 +273,29 @@ class ImagesPage(basepage.BaseNavigationPage): if description is not None: create_volume_form.description.text = description create_volume_form.image_source = name - create_volume_form.size.value = volume_size if volume_size \ + create_volume_form.volume_size.value = volume_size if volume_size \ else self.conf.volume.volume_size create_volume_form.availability_zone.value = \ self.conf.launch_instances.available_zone create_volume_form.submit() - return VolumesPage(self.driver, self.conf) def launch_instance_from_image(self, name, instance_name, instance_count=1, flavor=None): + instance_page = InstancesPage(self.driver, self.conf) row = self._get_row_with_image_name(name) - launch_instance = self.images_table.launch_instance(row) - launch_instance.availability_zone.value = \ + instance_form = self.images_table.launch_instance(row) + instance_form.availability_zone.value = \ self.conf.launch_instances.available_zone - launch_instance.name.text = instance_name + instance_form.name.text = instance_name + instance_form.count.value = instance_count + instance_form.switch_to(instance_page.SOURCE_STEP_INDEX) + instance_page.vol_delete_on_instance_delete_click() + instance_form.switch_to(instance_page.FLAVOR_STEP_INDEX) if flavor is None: flavor = self.conf.launch_instances.flavor - launch_instance.flavor.text = flavor - launch_instance.count.value = instance_count - launch_instance.submit() - return InstancesPage(self.driver, self.conf) - - -class ImagesPageNG(ImagesPage): - _resource_page_header_locator = (by.By.CSS_SELECTOR, - 'hz-resource-panel hz-page-header h1') - - @property - def header(self): - return self._get_element(*self._resource_page_header_locator) + instance_form.flavor.transfer_available_resource(flavor) + instance_form.switch_to(instance_page.NETWORKS_STEP_INDEX) + instance_form.network.transfer_available_resource( + instance_page.DEFAULT_NETWORK_TYPE) + instance_form.submit() + instance_form.wait_till_wizard_disappears() diff --git a/openstack_dashboard/test/integration_tests/regions/forms.py b/openstack_dashboard/test/integration_tests/regions/forms.py index 2476bbf19..2df2c50f8 100644 --- a/openstack_dashboard/test/integration_tests/regions/forms.py +++ b/openstack_dashboard/test/integration_tests/regions/forms.py @@ -231,6 +231,22 @@ class SelectFormFieldRegion(BaseFormFieldRegion): self.driver.execute_script(js_cmd) +class ButtonGroupFormFieldRegion(BaseFormFieldRegion): + """Select button group.""" + + _element_locator_str_suffix = 'div.btn-group' + _button_label_locator = (by.By.CSS_SELECTOR, 'label.btn') + + @property + def options(self): + options = self._get_elements(*self._button_label_locator) + results = {opt.text: opt for opt in options} + return results + + def pick(self, option): + return self.options[option].click() + + class ThemableSelectFormFieldRegion(BaseFormFieldRegion): """Select box field.""" @@ -422,6 +438,13 @@ class FormRegion(BaseFormRegion): return self._get_form_fields() +class FormRegionNG(FormRegion): + """Angular-based form.""" + + _fields_locator = (by.By.CSS_SELECTOR, 'div.content') + _submit_locator = (by.By.CSS_SELECTOR, '*.btn.btn-primary.finish') + + class TabbedFormRegion(FormRegion): """Forms that are divided with tabs. diff --git a/openstack_dashboard/test/integration_tests/regions/tables.py b/openstack_dashboard/test/integration_tests/regions/tables.py index 9778950b3..6d9334edd 100644 --- a/openstack_dashboard/test/integration_tests/regions/tables.py +++ b/openstack_dashboard/test/integration_tests/regions/tables.py @@ -45,6 +45,12 @@ class RowRegion(baseregion.BaseRegion): chck_box.click() +class RowRegionNG(RowRegion): + """Angular-based table row.""" + + _cell_locator = (by.By.CSS_SELECTOR, 'td > hz-cell') + + class TableRegion(baseregion.BaseRegion): """Basic class representing table object.""" @@ -253,6 +259,38 @@ class TableRegion(baseregion.BaseRegion): self.assertDictEqual(actual_table, expected_table_definition) +class TableRegionNG(TableRegion): + """Basic class representing angular-based table object.""" + + _heading_locator = (by.By.CSS_SELECTOR, + 'hz-resource-panel hz-page-header h1') + _empty_table_locator = (by.By.CSS_SELECTOR, 'tbody > tr > td.no-rows-help') + + def _table_locator(self, table_name): + return by.By.CSS_SELECTOR, 'hz-dynamic-table' + + @property + def _next_locator(self): + return by.By.CSS_SELECTOR, 'a[ng-click^="selectPage(currentPage + 1)"]' + + @property + def _prev_locator(self): + return by.By.CSS_SELECTOR, 'a[ng-click^="selectPage(currentPage - 1)"]' + + @property + def column_names(self): + names = [] + for element in self._get_elements(*self._columns_names_locator): + if element.text: + names.append(element.text) + return names + + def _get_rows(self, *args): + return [RowRegionNG(self.driver, self.conf, elem, self.column_names) + for elem in self._get_elements(*self._rows_locator) + if elem.text and elem.text != ''] + + def bind_table_action(action_name): """Decorator to bind table region method to an actual table action button. @@ -290,6 +328,44 @@ def bind_table_action(action_name): return decorator +def bind_table_action_ng(action_name): + """Decorator to bind table region method to an actual table action button. + + This decorator works with angular-based tables. + Many table actions when started (by clicking a corresponding button + in UI) lead to some form showing up. To further interact with this form, + a Python/ Selenium wrapper needs to be created for it. It is very + convenient to return this newly created wrapper in the same method that + initiates clicking an actual table action button. Binding the method to a + button is performed behind the scenes in this decorator. + + .. param:: action_name + + Part of the action button id which is specific to action itself. It + is safe to use action `name` attribute from the dashboard tables.py + code. + """ + _actions_locator = (by.By.CSS_SELECTOR, + 'actions.hz-dynamic-table-actions > action-list') + + def decorator(method): + @functools.wraps(method) + def wrapper(table): + actions = table._get_elements(*_actions_locator) + action_element = None + for action in actions: + if action.text == action_name: + action_element = action + break + if action_element is None: + msg = "Could not bind method '%s' to action control '%s'" % ( + method.__name__, action_name) + raise ValueError(msg) + return method(table, action_element) + return wrapper + return decorator + + def bind_row_action(action_name): """A decorator to bind table region method to an actual row action button. @@ -347,11 +423,67 @@ def bind_row_action(action_name): return decorator +def bind_row_action_ng(action_name): + """A decorator to bind table region method to an actual row action button. + + This decorator works with angular-based tables. + Many table actions when started (by clicking a corresponding button + in UI) lead to some form showing up. To further interact with this form, + a Python/ Selenium wrapper needs to be created for it. It is very + convenient to return this newly created wrapper in the same method that + initiates clicking an actual action button. Row action could be + either primary (if its name is written right away on row action + button) or secondary (if its name is inside of a button drop-down). Binding + the method to a button and toggling the button drop-down open (in case + a row action is secondary) is performed behind the scenes in this + decorator. + + .. param:: action_name + + Part of the action button id which is specific to action itself. It + is safe to use action `name` attribute from the dashboard tables.py + code. + """ + primary_action_locator = ( + by.By.CSS_SELECTOR, + 'td.actions_column > actions > action-list > button.split-button') + secondary_actions_opener_locator = ( + by.By.CSS_SELECTOR, + 'td.actions_column > actions > action-list > button.split-caret') + secondary_actions_locator = ( + by.By.CSS_SELECTOR, + 'td.actions_column > actions > action-list > ul > li > a') + + def decorator(method): + @functools.wraps(method) + def wrapper(table, row): + def find_action(element): + pattern = action_name + return element.text.endswith(pattern) + + action_element = row._get_element(*primary_action_locator) + if not find_action(action_element): + action_element = None + row._get_element(*secondary_actions_opener_locator).click() + for element in row._get_elements(*secondary_actions_locator): + if find_action(element): + action_element = element + break + + if action_element is None: + msg = "Could not bind method '%s' to action control '%s'" % ( + method.__name__, action_name) + raise ValueError(msg) + return method(table, action_element, row) + return wrapper + return decorator + + def bind_row_anchor_column(column_name): """A decorator to bind table region method to a anchor in a column. - Typical examples of such tables are Project -> Compute -> Images, Admin - -> System -> Flavors, Project -> Compute -> Instancies. + Typical examples of such tables are Project -> Compute -> Instances, Admin + -> System -> Flavors. The method can be used to follow the link in the anchor by the click. """ @@ -365,3 +497,24 @@ def bind_row_anchor_column(column_name): return wrapper return decorator + + +def bind_row_anchor_column_ng(column_name): + """A decorator to bind table region method to a anchor in a column. + + This decorator works with angular-based tables. + Typical examples of such tables are Project -> Compute -> Images, + Admin -> Compute -> Images. + The method can be used to follow the link in the anchor by the click. + """ + + def decorator(method): + @functools.wraps(method) + def wrapper(table, row): + cell = row.cells[column_name] + action_element = cell.find_element( + by.By.CSS_SELECTOR, 'td > hz-cell > a') + return method(table, action_element, row) + + return wrapper + return decorator diff --git a/openstack_dashboard/test/integration_tests/tests/test_images.py b/openstack_dashboard/test/integration_tests/tests/test_images.py index cd71ca2a6..8065eaa92 100644 --- a/openstack_dashboard/test/integration_tests/tests/test_images.py +++ b/openstack_dashboard/test/integration_tests/tests/test_images.py @@ -11,14 +11,16 @@ # under the License. import pytest -from openstack_dashboard.test.integration_tests import decorators from openstack_dashboard.test.integration_tests import helpers from openstack_dashboard.test.integration_tests.regions import messages +from openstack_dashboard.test.integration_tests.pages.project.\ + compute.instancespage import InstancesPage +from openstack_dashboard.test.integration_tests.pages.project.\ + volumes.volumespage import VolumesPage -@decorators.config_option_required('image.panel_type', 'legacy', - message="Angular Panels not tested") -class TestImagesLegacy(helpers.TestCase): + +class TestImagesBasicAngular(helpers.TestCase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.IMAGE_NAME = helpers.gen_random_resource_name("image") @@ -27,30 +29,10 @@ class TestImagesLegacy(helpers.TestCase): def images_page(self): return self.home_pg.go_to_project_compute_imagespage() - -@decorators.config_option_required('image.panel_type', 'angular', - message="Legacy Panels not tested") -class TestImagesAngular(helpers.TestCase): - @property - def images_page(self): - # FIXME(tsufiev): had to return angularized version of Images Page - # object with the horrendous hack below because it's not so easy to - # wire into the Navigation machinery and tell it to return an '*NG' - # version of ImagesPage class if one adds '_ng' suffix to - # 'go_to_compute_imagespage()' method. Yet that's how it should work - # (or rewrite Navigation module completely). - from openstack_dashboard.test.integration_tests.pages.project.\ - compute.imagespage import ImagesPageNG - self.home_pg.go_to_project_compute_imagespage() - return ImagesPageNG(self.driver, self.CONFIG) - def test_basic_image_browse(self): images_page = self.images_page self.assertEqual(images_page.header.text, 'Images') - -class TestImagesBasic(TestImagesLegacy): - """Login as demo user""" def image_create(self, local_file=None, **kwargs): images_page = self.images_page if local_file: @@ -58,8 +40,10 @@ class TestImagesBasic(TestImagesLegacy): image_file=local_file, **kwargs) else: - images_page.create_image(self.IMAGE_NAME, **kwargs) - self.assertTrue(images_page.find_message_and_dismiss(messages.INFO)) + images_page.create_image(self.IMAGE_NAME, + image_source_type='url', + **kwargs) + self.assertTrue(images_page.find_message_and_dismiss(messages.SUCCESS)) self.assertFalse(images_page.find_message_and_dismiss(messages.ERROR)) self.assertTrue(images_page.is_image_present(self.IMAGE_NAME)) self.assertTrue(images_page.is_image_active(self.IMAGE_NAME)) @@ -72,30 +56,30 @@ class TestImagesBasic(TestImagesLegacy): self.assertFalse(images_page.find_message_and_dismiss(messages.ERROR)) self.assertFalse(images_page.is_image_present(self.IMAGE_NAME)) - @pytest.mark.skip(reason="Bug 1595335") - def test_image_create_delete(self): + def test_image_create_delete_from_local_file(self): """tests the image creation and deletion functionalities: - * creates a new image from horizon.conf http_image + * creates a new image from a generated file * verifies the image appears in the images table as active * deletes the newly created image * verifies the image does not appear in the table after deletion """ - self.image_create() - self.image_delete(self.IMAGE_NAME) + with helpers.gen_temporary_file() as file_name: + self.image_create(local_file=file_name) + self.image_delete(self.IMAGE_NAME) - def test_image_create_delete_from_local_file(self): + # Run when Glance configuration and policies allow setting locations. + @pytest.mark.skip(reason="IMAGES_ALLOW_LOCATION = False") + def test_image_create_delete_from_url(self): """tests the image creation and deletion functionalities: - * downloads image from horizon.conf stated in http_image - * creates the image from the downloaded file + * creates a new image from horizon.conf http_image * verifies the image appears in the images table as active * deletes the newly created image * verifies the image does not appear in the table after deletion """ - with helpers.gen_temporary_file() as file_name: - self.image_create(local_file=file_name) - self.image_delete(self.IMAGE_NAME) + self.image_create() + self.image_delete(self.IMAGE_NAME) def test_images_pagination(self): """This test checks images pagination @@ -115,23 +99,51 @@ class TestImagesBasic(TestImagesLegacy): 9) Click 'Prev' and check results (should be the same as for step5) 10) Go to user settings page and restore 'Items Per Page' """ + default_image_list = self.CONFIG.image.images_list + + images_page = self.images_page + + # delete any old images except default ones + images_page.wait_until_image_present(default_image_list[0]) + image_list = images_page.images_table.get_column_data( + name_column='Name') + garbage = [i for i in image_list if i not in default_image_list] + if garbage: + images_page.delete_images(garbage) + self.assertTrue( + images_page.find_message_and_dismiss(messages.SUCCESS)) + items_per_page = 1 + images_count = 2 + images_names = ["{0}_{1}".format(self.IMAGE_NAME, item) + for item in range(images_count)] + for image_name in images_names: + with helpers.gen_temporary_file() as file_name: + images_page.create_image(image_name, image_file=file_name) + self.assertTrue( + images_page.find_message_and_dismiss(messages.SUCCESS)) + self.assertFalse( + images_page.find_message_and_dismiss(messages.ERROR)) + self.assertTrue(images_page.is_image_present(image_name)) + first_page_definition = {'Next': True, 'Prev': False, 'Count': items_per_page, 'Names': [default_image_list[0]]} second_page_definition = {'Next': True, 'Prev': True, 'Count': items_per_page, - 'Names': [default_image_list[1]]} + 'Names': [images_names[0]]} third_page_definition = {'Next': False, 'Prev': True, 'Count': items_per_page, - 'Names': [default_image_list[2]]} + 'Names': [images_names[1]]} settings_page = self.home_pg.go_to_settings_usersettingspage() settings_page.change_pagesize(items_per_page) settings_page.find_message_and_dismiss(messages.SUCCESS) images_page = self.images_page + if not images_page.is_image_present(default_image_list[0]): + images_page.wait_until_image_present(default_image_list[0]) images_page.images_table.assert_definition(first_page_definition) images_page.images_table.turn_next_page() @@ -150,6 +162,20 @@ class TestImagesBasic(TestImagesLegacy): settings_page.change_pagesize() settings_page.find_message_and_dismiss(messages.SUCCESS) + images_page = self.images_page + images_page.wait_until_image_present(default_image_list[0]) + images_page.delete_images(images_names) + self.assertTrue(images_page.find_message_and_dismiss(messages.SUCCESS)) + self.assertFalse(images_page.find_message_and_dismiss(messages.ERROR)) + + +class TestImagesAdminAngular(helpers.AdminTestCase, TestImagesBasicAngular): + """Login as admin user""" + + @property + def images_page(self): + return self.home_pg.go_to_admin_compute_imagespage() + def test_update_image_metadata(self): """Test update image metadata @@ -168,9 +194,6 @@ class TestImagesBasic(TestImagesLegacy): 'metadata2': helpers.gen_random_resource_name("value")} with helpers.gen_temporary_file() as file_name: - # TODO(tsufiev): had to add non-empty description to an image, - # because description is now considered a metadata and we want - # the metadata in a newly created image to be valid images_page = self.image_create(local_file=file_name, description='test description') images_page.add_custom_metadata(self.IMAGE_NAME, new_metadata) @@ -203,21 +226,24 @@ class TestImagesBasic(TestImagesLegacy): # Check that Delete action is not available in the action list. # The below action will generate exception since the bind fails. # But only ValueError with message below is expected here. - with self.assertRaisesRegex(ValueError, 'Could not bind method'): + message = "Could not bind method 'delete_image_via_row_action' " \ + "to action control 'Delete Image'" + with self.assertRaisesRegex(ValueError, message): images_page.delete_image_via_row_action(self.IMAGE_NAME) - # Try to delete image. That should not be possible now. - images_page.delete_image(self.IMAGE_NAME) - self.assertFalse( - images_page.find_message_and_dismiss(messages.SUCCESS)) - self.assertTrue( - images_page.find_message_and_dismiss(messages.ERROR)) - self.assertTrue(images_page.is_image_present(self.IMAGE_NAME)) + # Edit image to make it not protected again and delete it. + images_page = self.images_page images_page.edit_image(self.IMAGE_NAME, protected=False) self.assertTrue( images_page.find_message_and_dismiss(messages.SUCCESS)) + self.assertFalse( + images_page.find_message_and_dismiss(messages.ERROR)) + self.image_delete(self.IMAGE_NAME) + self.assertFalse( + images_page.find_message_and_dismiss(messages.ERROR)) + self.assertFalse(images_page.is_image_present(self.IMAGE_NAME)) def test_edit_image_description_and_name(self): """tests that image description is editable @@ -264,9 +290,53 @@ class TestImagesBasic(TestImagesLegacy): self.assertSequenceTrue(results) self.image_delete(new_image_name) + self.assertFalse( + images_page.find_message_and_dismiss(messages.ERROR)) + self.assertFalse(images_page.is_image_present(self.IMAGE_NAME)) + def test_filter_images(self): + """This test checks filtering of images + + Steps: + 1) Login to Horizon dashboard as admin user + 2) Go to Admin -> Compute -> Images + 3) Use filter by Image Name + 4) Check that filtered table has one image only (which name is + equal to filter value) + 5) Check that no other images in the table + 6) Clear filter and set nonexistent image name. Check that 0 rows + are displayed + """ + default_image_list = self.CONFIG.image.images_list + images_page = self.images_page + + images_page.filter(default_image_list[0]) + self.assertTrue(images_page.is_image_present(default_image_list[0])) + for image in default_image_list[1:]: + self.assertFalse(images_page.is_image_present(image)) + + images_page = self.images_page + nonexistent_image_name = "{0}_test".format(self.IMAGE_NAME) + images_page.filter(nonexistent_image_name) + self.assertEqual(images_page.images_table.rows, []) + + images_page.filter('') + + +class TestImagesAdvancedAngular(helpers.TestCase): + + @property + def images_page(self): + return self.home_pg.go_to_project_compute_imagespage() + + def volumes_page(self): + self.home_pg.go_to_project_volumes_volumespage() + return VolumesPage(self.driver, self.CONFIG) + + def instances_page(self): + self.home_pg.go_to_project_compute_instancespage() + return InstancesPage(self.driver, self.CONFIG) -class TestImagesAdvanced(TestImagesLegacy): """Login as demo user""" def test_create_volume_from_image(self): """This test case checks create volume from image functionality: @@ -282,18 +352,23 @@ class TestImagesAdvanced(TestImagesLegacy): source_image = self.CONFIG.image.images_list[0] target_volume = "created_from_{0}".format(source_image) - volumes_page = images_page.create_volume_from_image( + images_page.create_volume_from_image( source_image, volume_name=target_volume) self.assertTrue( - volumes_page.find_message_and_dismiss(messages.INFO)) + images_page.find_message_and_dismiss(messages.INFO)) self.assertFalse( - volumes_page.find_message_and_dismiss(messages.ERROR)) + images_page.find_message_and_dismiss(messages.ERROR)) + + volumes_page = self.volumes_page() + self.assertTrue(volumes_page.is_volume_present(target_volume)) self.assertTrue(volumes_page.is_volume_status(target_volume, 'Available')) volumes_page.delete_volume(target_volume) volumes_page.find_message_and_dismiss(messages.SUCCESS) volumes_page.find_message_and_dismiss(messages.ERROR) + + volumes_page = self.volumes_page() self.assertTrue(volumes_page.is_volume_deleted(target_volume)) def test_launch_instance_from_image(self): @@ -310,57 +385,22 @@ class TestImagesAdvanced(TestImagesLegacy): images_page = self.images_page source_image = self.CONFIG.image.images_list[0] target_instance = "created_from_{0}".format(source_image) - instances_page = images_page.launch_instance_from_image( - source_image, target_instance) + + images_page.launch_instance_from_image(source_image, target_instance) self.assertTrue( - instances_page.find_message_and_dismiss(messages.SUCCESS)) + images_page.find_message_and_dismiss(messages.INFO)) self.assertFalse( - instances_page.find_message_and_dismiss(messages.ERROR)) + images_page.find_message_and_dismiss(messages.ERROR)) + + instances_page = self.instances_page() self.assertTrue(instances_page.is_instance_active(target_instance)) + instances_page = self.instances_page() actual_image_name = instances_page.get_image_name(target_instance) self.assertEqual(source_image, actual_image_name) instances_page.delete_instance(target_instance) self.assertTrue( - instances_page.find_message_and_dismiss(messages.SUCCESS)) + instances_page.find_message_and_dismiss(messages.INFO)) self.assertFalse( instances_page.find_message_and_dismiss(messages.ERROR)) self.assertTrue(instances_page.is_instance_deleted(target_instance)) - - -class TestImagesAdmin(helpers.AdminTestCase, TestImagesLegacy): - """Login as admin user""" - @property - def images_page(self): - return self.home_pg.go_to_admin_compute_imagespage() - - @pytest.mark.skip(reason="Bug 1774697") - def test_image_create_delete(self): - super().test_image_create_delete() - - def test_filter_images(self): - """This test checks filtering of images - - Steps: - 1) Login to Horizon dashboard as admin user - 2) Go to Admin -> Compute -> Images - 3) Use filter by Image Name - 4) Check that filtered table has one image only (which name is - equal to filter value) - 5) Check that no other images in the table - 6) Clear filter and set nonexistent image name. Check that 0 rows - are displayed - """ - images_list = self.CONFIG.image.images_list - images_page = self.images_page - - images_page.images_table.filter(images_list[0]) - self.assertTrue(images_page.is_image_present(images_list[0])) - for image in images_list[1:]: - self.assertFalse(images_page.is_image_present(image)) - - nonexistent_image_name = "{0}_test".format(self.IMAGE_NAME) - images_page.images_table.filter(nonexistent_image_name) - self.assertEqual(images_page.images_table.rows, []) - - images_page.images_table.filter('') diff --git a/openstack_dashboard/test/integration_tests/tests/test_volumes.py b/openstack_dashboard/test/integration_tests/tests/test_volumes.py index 95108134b..a95e1ea25 100644 --- a/openstack_dashboard/test/integration_tests/tests/test_volumes.py +++ b/openstack_dashboard/test/integration_tests/tests/test_volumes.py @@ -242,6 +242,10 @@ class TestVolumesActions(helpers.TestCase): def volumes_page(self): return self.home_pg.go_to_project_volumes_volumespage() + @property + def images_page(self): + return self.home_pg.go_to_project_compute_imagespage() + def setUp(self): super().setUp() volumes_page = self.volumes_page @@ -288,7 +292,6 @@ class TestVolumesActions(helpers.TestCase): new_size = volumes_page.get_size(self.VOLUME_NAME) self.assertLess(orig_size, new_size) - @pytest.mark.skip(reason="Bug 1847715") def test_volume_upload_to_image(self): """This test case checks upload volume to image functionality: @@ -299,29 +302,28 @@ class TestVolumesActions(helpers.TestCase): 4. Delete the image 5. Repeat actions for all disk formats """ - self.volumes_page = self.home_pg.go_to_project_volumes_volumespage() - all_formats = {"qcow2": 'QCOW2', "raw": 'Raw', "vdi": 'VDI', + volumes_page = self.volumes_page + all_formats = {"qcow2": 'QCOW2', "raw": 'RAW', "vdi": 'VDI', "vmdk": 'VMDK'} for disk_format in all_formats: - self.volumes_page.upload_volume_to_image(self.VOLUME_NAME, - self.IMAGE_NAME, - disk_format) + volumes_page.upload_volume_to_image( + self.VOLUME_NAME, self.IMAGE_NAME, disk_format) self.assertFalse( - self.volumes_page.find_message_and_dismiss(messages.ERROR)) - self.assertTrue(self.volumes_page.is_volume_status( + volumes_page.find_message_and_dismiss(messages.ERROR)) + self.assertTrue(volumes_page.is_volume_status( self.VOLUME_NAME, 'Available')) - images_page = self.home_pg.go_to_project_compute_imagespage() + images_page = self.images_page self.assertTrue(images_page.is_image_present(self.IMAGE_NAME)) self.assertTrue(images_page.is_image_active(self.IMAGE_NAME)) self.assertEqual(images_page.get_image_format(self.IMAGE_NAME), all_formats[disk_format]) images_page.delete_image(self.IMAGE_NAME) self.assertTrue(images_page.find_message_and_dismiss( - messages.INFO)) + messages.SUCCESS)) self.assertFalse(images_page.find_message_and_dismiss( messages.ERROR)) self.assertFalse(images_page.is_image_present(self.IMAGE_NAME)) - self.volumes_page = \ + volumes_page = \ self.home_pg.go_to_project_volumes_volumespage() @pytest.mark.skip(reason="Bug 1774697") |