From 2a21acf87f21110d7ce77ad3b989ef32bf31b8a1 Mon Sep 17 00:00:00 2001 From: Yonas Berhe Date: Thu, 16 Jan 2025 14:50:52 -0800 Subject: [PATCH] automation: services tests --- cypress/e2e/po/components/growl-manager.po.ts | 23 +++ cypress/e2e/po/components/key-value.po.ts | 13 ++ cypress/e2e/po/components/tabbed.po.ts | 4 + cypress/e2e/po/edit/resource-detail.po.ts | 12 ++ cypress/e2e/po/edit/services.po.ts | 70 ++++++++- cypress/e2e/po/pages/explorer/services.po.ts | 6 +- cypress/e2e/po/pages/extensions.po.ts | 2 +- .../po/pages/global-settings/home-links.po.ts | 2 +- .../service-discovery/services.spec.ts | 142 +++++++++++++++++- .../tests/pages/extensions/extensions.spec.ts | 4 + shell/components/form/KeyValue.vue | 2 +- .../form/__tests__/KeyValue.test.ts | 2 +- 12 files changed, 262 insertions(+), 20 deletions(-) create mode 100644 cypress/e2e/po/components/growl-manager.po.ts create mode 100644 cypress/e2e/po/components/key-value.po.ts diff --git a/cypress/e2e/po/components/growl-manager.po.ts b/cypress/e2e/po/components/growl-manager.po.ts new file mode 100644 index 00000000000..b7aecd4c184 --- /dev/null +++ b/cypress/e2e/po/components/growl-manager.po.ts @@ -0,0 +1,23 @@ +import ComponentPo from '@/cypress/e2e/po/components/component.po'; + +export class GrowlManagerPo extends ComponentPo { + constructor() { + super('.growl-container'); + } + + growlList() { + return this.self().find('.growl-list'); + } + + growlMessage() { + return this.self().find('.growl-message'); + } + + dismissWarning() { + return this.self().find('.icon-close').click(); + } + + dismissAllWarnings() { + return this.self().find('button.btn').contains('Clear All Notifications').click(); + } +} diff --git a/cypress/e2e/po/components/key-value.po.ts b/cypress/e2e/po/components/key-value.po.ts new file mode 100644 index 00000000000..73f0353c1f2 --- /dev/null +++ b/cypress/e2e/po/components/key-value.po.ts @@ -0,0 +1,13 @@ +import ComponentPo from '@/cypress/e2e/po/components/component.po'; + +export default class KeyValuePo extends ComponentPo { + addButton(label: string) { + return this.self().find('[data-testid="add_row_item_button"]').contains(label); + } + + setKeyValueAtIndex(label: string, key: string, value: string, index: number, selector: string) { + this.addButton(label).click(); + this.self().find(`${ selector } [data-testid="input-kv-item-key-${ index }"]`).type(key); + this.self().find(`${ selector } [data-testid="kv-item-value-${ index }"]`).type(value); + } +} diff --git a/cypress/e2e/po/components/tabbed.po.ts b/cypress/e2e/po/components/tabbed.po.ts index 631e24a58be..cbbcaaef70b 100644 --- a/cypress/e2e/po/components/tabbed.po.ts +++ b/cypress/e2e/po/components/tabbed.po.ts @@ -17,6 +17,10 @@ export default class TabbedPo extends ComponentPo { return this.self().get('[data-testid="tabbed-block"] > li'); } + checkSelectedTab(selector: string) { + return this.self().find(`${ selector }`).should('have.class', 'active'); + } + /** * Get tab labels * @param tabLabelsSelector diff --git a/cypress/e2e/po/edit/resource-detail.po.ts b/cypress/e2e/po/edit/resource-detail.po.ts index 28c7e91387c..fb560e58703 100644 --- a/cypress/e2e/po/edit/resource-detail.po.ts +++ b/cypress/e2e/po/edit/resource-detail.po.ts @@ -4,14 +4,26 @@ import CruResourcePo from '@/cypress/e2e/po/components/cru-resource.po'; import ResourceYamlPo from '@/cypress/e2e/po/components/resource-yaml.po'; export default class ResourceDetailPo extends ComponentPo { + /** + * components for handling CRUD operations for resources, including cancel/save buttons + * @returns + */ cruResource() { return new CruResourcePo(this.self()); } + /** + * components for managing the resource creation and edit forms + * @returns + */ createEditView() { return new CreateEditViewPo(this.self()); } + /** + * components for YAML editor + * @returns + */ resourceYaml() { return new ResourceYamlPo(this.self()); } diff --git a/cypress/e2e/po/edit/services.po.ts b/cypress/e2e/po/edit/services.po.ts index a316910251d..6301b26cbdd 100644 --- a/cypress/e2e/po/edit/services.po.ts +++ b/cypress/e2e/po/edit/services.po.ts @@ -1,18 +1,76 @@ import PagePo from '@/cypress/e2e/po/pages/page.po'; +import NameNsDescription from '@/cypress/e2e/po/components/name-ns-description.po'; +import ResourceDetailPo from '@/cypress/e2e/po/edit/resource-detail.po'; +import LabeledSelectPo from '@/cypress/e2e/po/components/labeled-select.po'; +import TabbedPo from '@/cypress/e2e/po/components/tabbed.po'; +import LabeledInputPo from '@/cypress/e2e/po/components/labeled-input.po'; +import ArrayListPo from '@/cypress/e2e/po/components/array-list.po'; +import KeyValuePo from '@/cypress/e2e/po/components/key-value.po'; -export default class WorkloadsCreateEditPo extends PagePo { - private static createPath(clusterId: string, id?: string ) { - const root = `/c/${ clusterId }/explorer/storage.k8s.io.storageclass/create`; +export default class ServicesCreateEditPo extends PagePo { + private static createPath(clusterId: string, namespace?: string, id?: string ) { + const root = `/c/${ clusterId }/explorer/service`; - return id ? `${ root }/${ id }` : `${ root }/create`; + return id ? `${ root }/${ namespace }/${ id }` : `${ root }/create`; } static goTo(path: string): Cypress.Chainable { throw new Error('invalid'); } - constructor(clusterId = '_', id?: string) { - super(WorkloadsCreateEditPo.createPath(clusterId, id)); + constructor(clusterId = 'local', namespace?: string, id?: string) { + super(ServicesCreateEditPo.createPath(clusterId, namespace, id)); + } + + resourceDetail() { + return new ResourceDetailPo(this.self()); + } + + title() { + return this.self().get('.title .primaryheader h1'); + } + + nameNsDescription() { + return new NameNsDescription(this.self()); + } + + selectNamespace(label: string) { + const selectNs = new LabeledSelectPo(`[data-testid="name-ns-description-namespace"]`, this.self()); + + selectNs.toggle(); + selectNs.clickLabel(label); + } + + selectServiceOption(index: number) { + return this.resourceDetail().cruResource().selectSubTypeByIndex(index).click(); + } + + tabs() { + return new TabbedPo('[data-testid="tabbed"]'); + } + + externalNameTab() { + return this.tabs().clickTabWithSelector('[data-testid="define-external-name"]'); + } + + externalNameInput() { + return new LabeledInputPo('#define-external-name .labeled-input input'); + } + + ipAddressesTab() { + return this.tabs().clickTabWithSelector('[data-testid="ips"]'); + } + + ipAddressList() { + return new ArrayListPo('section#ips'); + } + + lablesAnnotationsTab() { + return this.tabs().clickTabWithSelector('[data-testid="btn-labels-and-annotations"]'); + } + + lablesAnnotationsKeyValue() { + return new KeyValuePo('section#labels-and-annotations'); } errorBanner() { diff --git a/cypress/e2e/po/pages/explorer/services.po.ts b/cypress/e2e/po/pages/explorer/services.po.ts index e9fbc44ae32..d5346480f12 100644 --- a/cypress/e2e/po/pages/explorer/services.po.ts +++ b/cypress/e2e/po/pages/explorer/services.po.ts @@ -26,7 +26,7 @@ export class ServicesPagePo extends PagePo { sideNav.navToSideMenuEntryByLabel('Service'); } - constructor(clusterId = 'local') { + constructor(private clusterId = 'local') { super(ServicesPagePo.createPath(clusterId)); } @@ -38,7 +38,7 @@ export class ServicesPagePo extends PagePo { return this.list().masthead().create(); } - createServicesForm(id? : string): ServicesCreateEditPo { - return new ServicesCreateEditPo(id); + createServicesForm(namespace?: string, id?: string): ServicesCreateEditPo { + return new ServicesCreateEditPo(this.clusterId, namespace, id); } } diff --git a/cypress/e2e/po/pages/extensions.po.ts b/cypress/e2e/po/pages/extensions.po.ts index ec2706bbf9d..9944a27b648 100644 --- a/cypress/e2e/po/pages/extensions.po.ts +++ b/cypress/e2e/po/pages/extensions.po.ts @@ -34,7 +34,7 @@ export default class ExtensionsPagePo extends PagePo { return this.title().should('contain', 'Extensions'); } - loading(options: any) { + loading() { return this.self().get('.data-loading'); } diff --git a/cypress/e2e/po/pages/global-settings/home-links.po.ts b/cypress/e2e/po/pages/global-settings/home-links.po.ts index c186ec02db0..d48c4a142f2 100644 --- a/cypress/e2e/po/pages/global-settings/home-links.po.ts +++ b/cypress/e2e/po/pages/global-settings/home-links.po.ts @@ -32,7 +32,7 @@ export class HomeLinksPagePo extends RootClusterPage { } addLinkButton() { - return cy.getId('add_link_button'); + return cy.getId('add_row_item_button'); } removeLinkButton() { diff --git a/cypress/e2e/tests/pages/explorer/service-discovery/services.spec.ts b/cypress/e2e/tests/pages/explorer/service-discovery/services.spec.ts index 43aa56c97e6..41dcd9e880e 100644 --- a/cypress/e2e/tests/pages/explorer/service-discovery/services.spec.ts +++ b/cypress/e2e/tests/pages/explorer/service-discovery/services.spec.ts @@ -1,13 +1,147 @@ import { ServicesPagePo } from '@/cypress/e2e/po/pages/explorer/services.po'; import { generateServicesDataSmall, servicesNoData } from '@/cypress/e2e/blueprints/explorer/workloads/service-discovery/services-get'; import ClusterDashboardPagePo from '@/cypress/e2e/po/pages/explorer/cluster-dashboard.po'; +import PromptRemove from '@/cypress/e2e/po/prompts/promptRemove.po'; +import { GrowlManagerPo } from '@/cypress/e2e/po/components/growl-manager.po'; -const cluster = 'local'; const servicesPagePo = new ServicesPagePo(); +const growlPo = new GrowlManagerPo(); +const cluster = 'local'; +let serviceExternalName = ''; +const namespace = 'default'; +let removeServices = false; +const servicesToDelete = []; describe('Services', { testIsolation: 'off', tags: ['@explorer', '@adminUser'] }, () => { before(() => { cy.login(); + cy.createE2EResourceName('serviceexternalname').then((name) => { + serviceExternalName = name; + }); + }); + + describe('CRUD', () => { + it('can create an ExternalName Service', () => { + cy.intercept('POST', '/v1/services').as('createService'); + ServicesPagePo.navTo(); + servicesPagePo.waitForPage(); + servicesPagePo.clickCreate(); + servicesPagePo.createServicesForm().waitForPage(); + + servicesPagePo.createServicesForm().selectServiceOption(1); + servicesPagePo.createServicesForm().waitForPage(null, 'define-external-name'); + servicesPagePo.createServicesForm().resourceDetail().title().should('contain', 'Create ExternalName'); + servicesPagePo.createServicesForm().nameNsDescription().name().set(serviceExternalName); + servicesPagePo.createServicesForm().nameNsDescription().description().set(`${ serviceExternalName }-desc`); + servicesPagePo.createServicesForm().selectNamespace(namespace); + servicesPagePo.createServicesForm().tabs().allTabs().should('have.length', 3); + + const tabs = ['External Name', 'IP Addresses', 'Labels & Annotations']; + + servicesPagePo.createServicesForm().tabs().tabNames().each((el, i) => { + expect(el).to.eq(tabs[i]); + }); + + servicesPagePo.createServicesForm().tabs().checkSelectedTab('[data-testid="define-external-name"]'); + servicesPagePo.createServicesForm().externalNameInput().set('my.database.example.com'); + servicesPagePo.createServicesForm().ipAddressesTab(); + servicesPagePo.createServicesForm().waitForPage(null, 'ips'); + servicesPagePo.createServicesForm().ipAddressList().setValueAtIndex('1.1.1.1', 0); + servicesPagePo.createServicesForm().ipAddressList().setValueAtIndex('2.2.2.2', 1); + servicesPagePo.createServicesForm().lablesAnnotationsTab(); + servicesPagePo.createServicesForm().waitForPage(null, 'labels-and-annotations'); + servicesPagePo.createServicesForm().lablesAnnotationsKeyValue().setKeyValueAtIndex('Add Label', 'label-key1', 'label-value1', 0, '.labels-and-annotations-container div.row:nth-of-type(2)'); + + // Adding Annotations doesn't work via test automation + // See https://github.com/rancher/dashboard/issues/13191 + // servicesPagePo.createServicesForm().lablesAnnotationsKeyValue().setKeyValueAtIndex('Add Annotation', 'ann-key1', 'ann-value1', 0, '.labels-and-annotations-container div.row:nth-of-type(3)'); + servicesPagePo.createServicesForm().resourceDetail().createEditView().create(); + cy.wait('@createService').then(({ response }) => { + expect(response?.statusCode).to.eq(201); + removeServices = true; + servicesToDelete.push(`${ namespace }/${ serviceExternalName }`); + }); + servicesPagePo.waitForPage(); + servicesPagePo.list().resourceTable().sortableTable().rowWithName(serviceExternalName) + .checkVisible(); + growlPo.dismissWarning(); + }); + + it('can edit an ExternalName Service', () => { + ServicesPagePo.navTo(); + servicesPagePo.waitForPage(); + servicesPagePo.list().actionMenu(serviceExternalName).getMenuItem('Edit Config').click(); + servicesPagePo.createServicesForm(namespace, serviceExternalName).waitForPage('mode=edit', 'define-external-name'); + servicesPagePo.createServicesForm().nameNsDescription().description().set(`${ serviceExternalName }-desc`); + servicesPagePo.createServicesForm().resourceDetail().cruResource().saveAndWaitForRequests('PUT', `/v1/services/${ namespace }/${ serviceExternalName }`) + .then(({ response }) => { + expect(response?.statusCode).to.eq(200); + expect(response?.body.metadata).to.have.property('name', serviceExternalName); + expect(response?.body.metadata.annotations).to.have.property('field.cattle.io/description', `${ serviceExternalName }-desc`); + }); + servicesPagePo.waitForPage(); + growlPo.dismissWarning(); + }); + + it('can clone an ExternalName Service', () => { + ServicesPagePo.navTo(); + servicesPagePo.waitForPage(); + servicesPagePo.list().actionMenu(serviceExternalName).getMenuItem('Clone').click(); + servicesPagePo.createServicesForm(namespace, serviceExternalName).waitForPage('mode=clone', 'define-external-name'); + servicesPagePo.createServicesForm().nameNsDescription().name().set(`clone-${ serviceExternalName }`); + servicesPagePo.createServicesForm().resourceDetail().cruResource().saveAndWaitForRequests('POST', '/v1/services') + .then(({ response }) => { + expect(response?.statusCode).to.eq(201); + expect(response?.body.metadata).to.have.property('name', `clone-${ serviceExternalName }`); + removeServices = true; + servicesToDelete.push(`${ namespace }/clone-${ serviceExternalName }`); + }); + servicesPagePo.waitForPage(); + servicesPagePo.list().resourceTable().sortableTable().rowWithName(`clone-${ serviceExternalName }`) + .checkVisible(); + growlPo.dismissWarning(); + }); + + it('can Edit Yaml', () => { + ServicesPagePo.navTo(); + servicesPagePo.waitForPage(); + servicesPagePo.list().actionMenu(`clone-${ serviceExternalName }`).getMenuItem('Edit YAML').click(); + servicesPagePo.createServicesForm(namespace, `clone-${ serviceExternalName }`).waitForPage('mode=edit&as=yaml'); + servicesPagePo.createServicesForm().title().contains(`Service: clone-${ serviceExternalName }`).should('be.visible'); + }); + + it('can delete an ExternalName Service', () => { + ServicesPagePo.navTo(); + servicesPagePo.waitForPage(); + servicesPagePo.list().actionMenu(`clone-${ serviceExternalName }`).getMenuItem('Delete').click(); + servicesPagePo.list().resourceTable().sortableTable().rowNames('.col-link-detail') + .then((rows: any) => { + const promptRemove = new PromptRemove(); + + cy.intercept('DELETE', `/v1/services/${ namespace }/clone-${ serviceExternalName }`).as('deleteService'); + + promptRemove.remove(); + cy.wait('@deleteService'); + servicesPagePo.waitForPage(); + servicesPagePo.list().resourceTable().sortableTable().checkRowCount(false, rows.length - 1); + servicesPagePo.list().resourceTable().sortableTable().rowNames('.col-link-detail') + .should('not.contain', `clone-${ serviceExternalName }`); + }); + }); + + // testing https://github.com/rancher/dashboard/issues/11889 + it('validation errors should not be shown when form is just opened', () => { + servicesPagePo.goTo(); + servicesPagePo.clickCreate(); + servicesPagePo.createServicesForm().errorBanner().should('not.exist'); + }); + + after(() => { + if (removeServices) { + // delete gitrepo + servicesToDelete.forEach((r) => cy.deleteRancherResource('v1', 'services', r, false)); + } + }); }); describe('List', { tags: ['@vai', '@adminUser'] }, () => { @@ -84,12 +218,6 @@ describe('Services', { testIsolation: 'off', tags: ['@explorer', '@adminUser'] } servicesPagePo.list().resourceTable().sortableTable().checkRowCount(false, 3); }); - it('validation errors should not be shown when form is just opened', () => { - servicesPagePo.goTo(); - servicesPagePo.clickCreate(); - servicesPagePo.createServicesForm().errorBanner().should('not.exist'); - }); - after('clean up', () => { cy.updateNamespaceFilter(cluster, 'none', '{"local":["all://user"]}'); }); diff --git a/cypress/e2e/tests/pages/extensions/extensions.spec.ts b/cypress/e2e/tests/pages/extensions/extensions.spec.ts index f208c05fc66..207651731fd 100644 --- a/cypress/e2e/tests/pages/extensions/extensions.spec.ts +++ b/cypress/e2e/tests/pages/extensions/extensions.spec.ts @@ -195,6 +195,7 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => { // Ensure that the banner should be shown (by confirming that a required repo isn't there) appRepoList.goTo(); appRepoList.waitForPage(); + appRepoList.sortableTable().checkLoadingIndicatorNotVisible(); appRepoList.sortableTable().noRowsShouldNotExist(); appRepoList.sortableTable().rowNames().then((names: any) => { if (names.includes(UI_PLUGINS_PARTNERS_REPO_NAME)) { @@ -409,6 +410,7 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => { extensionsPo.extensionTabAvailableClick(); extensionsPo.waitForPage(null, 'available'); + extensionsPo.loading().should('not.exist'); // Install unauthenticated extension extensionsPo.extensionCardInstallClick(UNAUTHENTICATED_EXTENSION_NAME); @@ -418,6 +420,8 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => { // let's check the extension reload banner and reload the page extensionsPo.extensionReloadBanner().should('be.visible'); extensionsPo.extensionReloadClick(); + extensionsPo.waitForPage(null, 'installed'); + extensionsPo.loading().should('not.exist'); // make sure both extensions have been imported extensionsPo.extensionScriptImport(UNAUTHENTICATED_EXTENSION_NAME).should('exist'); diff --git a/shell/components/form/KeyValue.vue b/shell/components/form/KeyValue.vue index 30698c086bc..ee836c11a4e 100644 --- a/shell/components/form/KeyValue.vue +++ b/shell/components/form/KeyValue.vue @@ -798,7 +798,7 @@ export default { v-if="addAllowed" type="button" class="btn role-tertiary add" - data-testid="add_link_button" + data-testid="add_row_item_button" :disabled="loading || disabled || (keyOptions && filteredKeyOptions.length === 0)" @click="add()" > diff --git a/shell/components/form/__tests__/KeyValue.test.ts b/shell/components/form/__tests__/KeyValue.test.ts index 5489d395005..11bcd78feba 100644 --- a/shell/components/form/__tests__/KeyValue.test.ts +++ b/shell/components/form/__tests__/KeyValue.test.ts @@ -127,7 +127,7 @@ describe('component: KeyValue', () => { expect(secondKeyInput.exists()).toBe(false); expect(secondValueInput.exists()).toBe(false); - const addButton = wrapper.find('[data-testid="add_link_button"]'); + const addButton = wrapper.find('[data-testid="add_row_item_button"]'); addButton.trigger('click'); await nextTick();