diff --git a/cypress/e2e/po/components/list-row.po.ts b/cypress/e2e/po/components/list-row.po.ts index 2e8cc4e3b2c..1281bb40db3 100644 --- a/cypress/e2e/po/components/list-row.po.ts +++ b/cypress/e2e/po/components/list-row.po.ts @@ -1,8 +1,13 @@ -import ComponentPo from '@/cypress/e2e/po/components/component.po'; +import ComponentPo, { GetOptions } from '@/cypress/e2e/po/components/component.po'; export default class ListRowPo extends ComponentPo { - column(index: number) { - return this.self().find('td').eq(index); + /** + * @param options timeout for the column lookup. Cypress timeouts are per + * command, so the timeout given to whatever produced the row does not carry + * over and the column otherwise falls back to `defaultCommandTimeout`. + */ + column(index: number, options?: GetOptions) { + return this.self().find('td', options).eq(index); } /** diff --git a/cypress/e2e/po/components/sortable-table.po.ts b/cypress/e2e/po/components/sortable-table.po.ts index 85a3e212ad4..ca8138fbde1 100644 --- a/cypress/e2e/po/components/sortable-table.po.ts +++ b/cypress/e2e/po/components/sortable-table.po.ts @@ -178,8 +178,8 @@ export default class SortableTablePo extends ComponentPo { return new ListRowPo(this.rowElementWithPartialName(name)); } - rowWithName(name: string) { - return new ListRowPo(this.rowElementWithName(name)); + rowWithName(name: string, options?: GetOptions) { + return new ListRowPo(this.rowElementWithName(name, options)); } /** diff --git a/cypress/e2e/po/detail/provisioning.cattle.io.cluster/cluster-detail.po.ts b/cypress/e2e/po/detail/provisioning.cattle.io.cluster/cluster-detail.po.ts index 2c08437e73e..f56536a7f21 100644 --- a/cypress/e2e/po/detail/provisioning.cattle.io.cluster/cluster-detail.po.ts +++ b/cypress/e2e/po/detail/provisioning.cattle.io.cluster/cluster-detail.po.ts @@ -7,6 +7,7 @@ import ClusterSnapshotsListPo from '~/cypress/e2e/po/lists/cluster-snapshots-lis import TabbedPo from '~/cypress/e2e/po/components/tabbed.po'; import ClusterRecentEventsListPo from '~/cypress/e2e/po/lists/cluster-recent-events-list.po'; import DetailDrawer from '@/cypress/e2e/po/side-bars/detail-drawer.po'; +import { GetOptions } from '@/cypress/e2e/po/components/component.po'; /** * Covers core functionality that's common to the dashboard's cluster detail pages @@ -42,8 +43,18 @@ export default abstract class ClusterManagerDetailPagePo extends BaseDetailPageP return new ClusterProvisioningLogPo(this.self()).logsContainer(options); } - kubectlCommandForImported() { - return this.self().get('code'); + /** + * Tab button for the registration tab. + * + * Only rendered once the cluster registration token has resolved, so callers + * generally need to pass a timeout. + */ + registrationTab(options?: GetOptions) { + return this.self().get('[data-testid="btn-registration"]', options); + } + + kubectlCommandForImported(options?: GetOptions) { + return this.self().get('code', options); } poolsList(tabId: 'machine' | 'node') { diff --git a/cypress/e2e/po/lists/provisioning.cattle.io.cluster.po.ts b/cypress/e2e/po/lists/provisioning.cattle.io.cluster.po.ts index 910cbd17b29..d5eb9d7b921 100644 --- a/cypress/e2e/po/lists/provisioning.cattle.io.cluster.po.ts +++ b/cypress/e2e/po/lists/provisioning.cattle.io.cluster.po.ts @@ -1,4 +1,5 @@ import BaseResourceList from '@/cypress/e2e/po/lists/base-resource-list.po'; +import { GetOptions } from '@/cypress/e2e/po/components/component.po'; /** * List component for provisioning.cattle.io.cluster resources @@ -20,8 +21,8 @@ export default class ProvClusterListPo extends BaseResourceList { return this.resourceTable().sortableTable().rowWithName(name).column(index); } - state(clusterName: string) { - return this.resourceTable().sortableTable().rowWithName(clusterName).column(1); + state(clusterName: string, options?: GetOptions) { + return this.resourceTable().sortableTable().rowWithName(clusterName, options).column(1, options); } name(clusterName: string) { diff --git a/cypress/e2e/po/pages/cluster-manager/cluster-manager-list.po.ts b/cypress/e2e/po/pages/cluster-manager/cluster-manager-list.po.ts index 5e53638ed14..d4404dfe35f 100644 --- a/cypress/e2e/po/pages/cluster-manager/cluster-manager-list.po.ts +++ b/cypress/e2e/po/pages/cluster-manager/cluster-manager-list.po.ts @@ -1,6 +1,7 @@ import { BaseListPagePo } from '@/cypress/e2e/po/pages/base/base-list-page.po'; import ProvClusterListPo from '@/cypress/e2e/po/lists/provisioning.cattle.io.cluster.po'; import BurgerMenuPo from '@/cypress/e2e/po/side-bars/burger-side-menu.po'; +import { GetOptions } from '@/cypress/e2e/po/components/component.po'; /** * List page for management.cattle.io.cluster resources @@ -94,6 +95,20 @@ export default class ClusterManagerListPagePo extends BaseListPagePo { return new ProvClusterListPo('[data-testid="cluster-list"]'); } + /** + * Waits until the cluster list is rendered. + * + * `ResourceList` loads the per-type list component through a dynamic import, + * so this container does not exist until that chunk has been fetched and + * mounted. The first navigation of a run needs a generous timeout for that. + * + * Waits on the list rather than on an action button, which actions render + * depends on the user's permissions. + */ + waitForListReady(options?: GetOptions) { + return this.list().checkVisible(options); + } + /** * Convenience method */ @@ -101,9 +116,20 @@ export default class ClusterManagerListPagePo extends BaseListPagePo { return this.list().resourceTable().sortableTable(); } + /** + * Import button in the list masthead. + * + * Found by test id rather than by position. The button is conditional on + * `canImport`, so an index based lookup silently resolves to the create + * button whenever it is absent. The masthead is a sibling of `cluster-list`, + * so this cannot be scoped to the list container. + */ + importClusterButton(options?: GetOptions) { + return cy.get('[data-testid="cluster-manager-list-import"]', options); + } + importCluster() { - return this.list().masthead().actions().eq(0) - .click(); + return this.importClusterButton().click(); } createCluster() { diff --git a/cypress/e2e/tests/pages/manager/cluster-manager.spec.ts b/cypress/e2e/tests/pages/manager/cluster-manager.spec.ts index 6d113cc3f08..82c55aea08b 100644 --- a/cypress/e2e/tests/pages/manager/cluster-manager.spec.ts +++ b/cypress/e2e/tests/pages/manager/cluster-manager.spec.ts @@ -438,6 +438,15 @@ describe('Cluster Manager', { testIsolation: false, tags: ['@manager', '@adminUs const cacert = 'cacert'; const privateRegistry = 'registry.io'; + before(() => { + // Absorb the cold start here rather than inside a test. The list + // component is pulled in through a dynamic import, so the first + // navigation of a run has to fetch it before the page has any content. + clusterList.goTo(); + clusterList.checkIsCurrentPage(); + clusterList.waitForListReady(EXTRA_LONG_TIMEOUT_OPT); + }); + describe('Generic', () => { qase(1436, it('can create new cluster', { retries: 0 }, () => { importGenericName = createClusterTestName('import-generic'); @@ -446,7 +455,7 @@ describe('Cluster Manager', { testIsolation: false, tags: ['@manager', '@adminUs clusterList.goTo(); clusterList.checkIsCurrentPage(); - clusterList.list().checkVisible(MEDIUM_TIMEOUT_OPT); + clusterList.waitForListReady(MEDIUM_TIMEOUT_OPT); clusterList.importCluster(); importClusterPage.waitForPage('mode=import'); @@ -470,8 +479,9 @@ describe('Cluster Manager', { testIsolation: false, tags: ['@manager', '@adminUs importClusterPage.create(); - cy.wait('@importRequest').then((intercept) => { - expect(intercept.response.statusCode).to.eq(201); + cy.wait('@importRequest', { requestTimeout: LONG_TIMEOUT_OPT.timeout, responseTimeout: LONG_TIMEOUT_OPT.timeout }).then((intercept) => { + // Fail fast if the backend rejects the import. + expect(intercept.response?.statusCode, 'Cluster import POST status').to.eq(201); expect(intercept.request.body).to.deep.equal({ type: importType, agentEnvVars: [], @@ -485,12 +495,35 @@ describe('Cluster Manager', { testIsolation: false, tags: ['@manager', '@adminUs cy.getClusterIdByName(importGenericName).then((clusterId) => { const detailClusterPage = new ClusterManagerDetailImportedGenericPagePo(undefined, clusterId); - cy.url(EXTRA_LONG_TIMEOUT_OPT).should('include', `${ clusterId }#registration`); - detailClusterPage.kubectlCommandForImported().contains('--insecure').then(($value) => { + // The create form does not reliably redirect after an import, see + // https://github.com/rancher/dashboard/issues/18712. Go to the detail + // page directly, restore the assertion once that is fixed. + detailClusterPage.goTo(); + detailClusterPage.waitForPage(undefined, undefined, LONG_TIMEOUT_OPT); + + // The `#registration` fragment is a side effect of Tabbed picking a + // default tab, which it does once, while no tab is active. If the + // registration token is slow another tab wins and the fragment is + // never written, so a longer timeout cannot fix it. Click the tab. + detailClusterPage.registrationTab(LONG_TIMEOUT_OPT).click(); + detailClusterPage.waitForPage(undefined, 'registration', MEDIUM_TIMEOUT_OPT); + + detailClusterPage.kubectlCommandForImported(MEDIUM_TIMEOUT_OPT).contains('--insecure').then(($value) => { const kubectlCommand = $value.text(); expect(kubectlCommand).to.contain('--insecure'); cy.log(kubectlCommand); + + // Assert the waiting state before applying the manifest, where no + // agent can have connected yet. After the apply it is a race. + ClusterManagerListPagePo.navTo(); + clusterList.waitForPage(); + clusterList.list().state(importGenericName, MEDIUM_TIMEOUT_OPT).should(($el) => { + const status = $el.text().trim(); + + expect(['Pending', 'Provisioning', 'Waiting']).to.include(status); + }); + cy.exec(kubectlCommand, { failOnNonZeroExit: false, timeout: RESTART_TIMEOUT_OPT.timeout }).then((result) => { cy.log(result.stderr); cy.log(result.stdout); @@ -501,13 +534,7 @@ describe('Cluster Manager', { testIsolation: false, tags: ['@manager', '@adminUs ClusterManagerListPagePo.navTo(); clusterList.waitForPage(); - clusterList.list().state(importGenericName).should('be.visible', EXTRA_LONG_TIMEOUT_OPT) - .and(($el) => { - const status = $el.text().trim(); - - expect(['Pending', 'Provisioning', 'Waiting']).to.include(status); - }); - clusterList.list().state(importGenericName).contains('Active', EXTRA_LONG_TIMEOUT_OPT); + clusterList.list().state(importGenericName, MEDIUM_TIMEOUT_OPT).contains('Active', EXTRA_LONG_TIMEOUT_OPT); // Issue #6836: Provider field on Imported clusters states "Imported" instead of cluster type clusterList.list().provider(importGenericName).should('contain.text', 'Imported'); clusterList.list().providerSubType(importGenericName).should('contain.text', 'K3s'); @@ -535,7 +562,10 @@ describe('Cluster Manager', { testIsolation: false, tags: ['@manager', '@adminUs // verify that clicking an accordion label in the table of contents scrolls the page to the associated accordion and opens it cy.get('[data-testid="toc-list-item-3"] button').click(); - cy.window().its('scrollY').should('be.greaterThan', 0); + // The dashboard scrolls an inner container, not the window, so + // window.scrollY stays at 0. Assert on the element the app actually + // scrolls (see Tabbed's hashChange, which resets `main`'s scrollTop). + cy.get('main').its('0.scrollTop').should('be.greaterThan', 0); cy.get('[data-testid="registries-accordion"]') .find('[data-testid="accordion-body"]') @@ -547,10 +577,17 @@ describe('Cluster Manager', { testIsolation: false, tags: ['@manager', '@adminUs const editImportedClusterPage = new ClusterManagerEditImportedPagePo(undefined, 'fleet-default', clusterId); cy.intercept('GET', `${ USERS_BASE_URL }?*`).as('pageLoad'); + // goTo() rather than navTo(), the pageLoad intercept needs a full page + // load. An SPA navigation leaves the store populated from the previous + // test, so the users request never fires again. + // Wait for the list and the row before touching the action menu. clusterList.goTo(); - clusterList.list().actionMenu(importGenericName).getMenuItem('Edit Config').click(); + clusterList.waitForPage(); + clusterList.waitForListReady(MEDIUM_TIMEOUT_OPT); + clusterList.sortableTable().rowElementWithName(importGenericName, MEDIUM_TIMEOUT_OPT).should('be.visible').scrollIntoView(); + clusterList.list().actionMenu(importGenericName).getMenuItem('Edit Config').click({ force: true }); - editImportedClusterPage.waitForPage('mode=edit'); + editImportedClusterPage.waitForPage('mode=edit', undefined, LONG_TIMEOUT_OPT); editImportedClusterPage.nameNsDescription().name().value().should('eq', importGenericName); cy.wait('@pageLoad'); @@ -584,12 +621,14 @@ describe('Cluster Manager', { testIsolation: false, tags: ['@manager', '@adminUs editImportedClusterPage.save(); - // We should be taken back to the list page if the save was successful - clusterList.waitForPage(); + // We should be taken back to the list page if the save was successful. + // The save PUT and redirect can exceed the default timeout under load. + clusterList.waitForPage(undefined, undefined, LONG_TIMEOUT_OPT); - clusterList.list().actionMenu(importGenericName).getMenuItem('Edit Config').click(); + clusterList.sortableTable().rowElementWithName(importGenericName, MEDIUM_TIMEOUT_OPT).should('be.visible').scrollIntoView(); + clusterList.list().actionMenu(importGenericName).getMenuItem('Edit Config').click({ force: true }); - editImportedClusterPage.waitForPage('mode=edit'); + editImportedClusterPage.waitForPage('mode=edit', undefined, LONG_TIMEOUT_OPT); editImportedClusterPage.ace().fqdn().value().should('eq', fqdn ); editImportedClusterPage.ace().caCerts().value().should('eq', cacert ); @@ -601,8 +640,11 @@ describe('Cluster Manager', { testIsolation: false, tags: ['@manager', '@adminUs qase(1463, it('can delete cluster by bulk actions', () => { clusterList.goTo(); - clusterList.list().checkVisible(MEDIUM_TIMEOUT_OPT); - clusterList.sortableTable().rowElementWithName(importGenericName).should('exist', MEDIUM_TIMEOUT_OPT); + clusterList.waitForPage(); + clusterList.waitForListReady(MEDIUM_TIMEOUT_OPT); + // Timeouts belong on the command that yields the element; .should() + // ignores an options argument (it is read as the assertion message). + clusterList.sortableTable().rowElementWithName(importGenericName, MEDIUM_TIMEOUT_OPT).should('be.visible').scrollIntoView(); clusterList.sortableTable().rowSelectCtlWithName(importGenericName).set(); clusterList.sortableTable().bulkActionDropDownOpen(); clusterList.sortableTable().bulkActionDropDownButton('Delete').click(); @@ -612,8 +654,10 @@ describe('Cluster Manager', { testIsolation: false, tags: ['@manager', '@adminUs promptRemove.confirm(importGenericName); promptRemove.remove(); - clusterList.waitForPage(); - clusterList.sortableTable().rowElementWithName(importGenericName).should('not.exist'); + clusterList.waitForPage(undefined, undefined, LONG_TIMEOUT_OPT); + // Cluster removal is asynchronous, the row lingers while the backend + // finalizes the delete. + clusterList.sortableTable().rowElementWithName(importGenericName, EXTRA_LONG_TIMEOUT_OPT).should('not.exist'); })); }); });