From d0f89856c00899d8074a2feea9eb983f80f6f872 Mon Sep 17 00:00:00 2001 From: Izaac Zavaleta Date: Thu, 6 Aug 2026 17:46:10 -0700 Subject: [PATCH] Stabilize imported generic cluster e2e spec The tests in the Imported block were failing before they reached anything they were meant to cover. Absorb the cold start of the dynamically imported list component in a before hook, select the registration tab explicitly rather than waiting for the `#registration` fragment that Tabbed only writes as a side effect of picking a default tab, assert the intermediate waiting state before the registration manifest is applied rather than after, move timeouts off `should()` where they were read as the assertion message and onto the commands that yield the element, thread them through `ListRowPo.column` so they survive narrowing a row to a cell, target the import button by its own test id rather than by position in the masthead, navigate to the detail page instead of gating on the create form redirect which is unreliable after an import (see rancher/dashboard#18712), and assert the table of contents scroll against the inner container the dashboard actually scrolls rather than the window. --- cypress/e2e/po/components/list-row.po.ts | 11 ++- .../e2e/po/components/sortable-table.po.ts | 4 +- .../cluster-detail.po.ts | 15 +++- .../provisioning.cattle.io.cluster.po.ts | 5 +- .../cluster-manager-list.po.ts | 30 ++++++- .../pages/manager/cluster-manager.spec.ts | 90 ++++++++++++++----- 6 files changed, 121 insertions(+), 34 deletions(-) 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'); })); }); });