From aaff654683ec6d35ad1e509f568c874b11544fb6 Mon Sep 17 00:00:00 2001 From: Tayler Geiger Date: Thu, 2 Jul 2026 09:17:53 -0500 Subject: [PATCH] [CONSOLE-5237] Migrate OLM Cypress tests to Playwright --- frontend/e2e/clients/kubernetes-client.ts | 88 ++++ frontend/e2e/pages/catalog-page.ts | 77 +++ frontend/e2e/pages/catalog-source-page.ts | 72 +++ frontend/e2e/pages/details-page.ts | 13 + .../e2e/pages/installed-operators-page.ts | 215 ++++++++ frontend/e2e/pages/operand-page.ts | 80 +++ frontend/e2e/pages/operator-details-page.ts | 338 +++++++++++++ .../e2e/pages/operator-hub-details-page.ts | 100 ++++ frontend/e2e/pages/operator-install-page.ts | 172 +++++++ frontend/e2e/pages/overview-page.ts | 1 + frontend/e2e/pages/yaml-editor-page.ts | 12 + frontend/e2e/test-utils/cluster-cleanup.ts | 208 ++++++++ frontend/e2e/test-utils/olm-cleanup.ts | 116 +++++ frontend/e2e/test-utils/olm-test-cleanup.ts | 177 +++++++ frontend/e2e/test-utils/operator-cleanup.ts | 141 ++++++ frontend/e2e/test-utils/test-namespace.ts | 12 + .../console/crud/add-storage-crud.spec.ts | 1 + .../tests/console/crud/annotations.spec.ts | 2 +- .../crud/customresourcedefinition.spec.ts | 2 - .../tests/console/crud/other-routes.spec.ts | 3 +- .../e2e/tests/console/crud/quotas.spec.ts | 16 +- .../tests/olm/catalog-source-details.spec.ts | 135 +++++ .../e2e/tests/olm/create-namespace.spec.ts | 88 ++-- frontend/e2e/tests/olm/descriptors.spec.ts | 470 ++++++++++++++++++ .../tests/olm/edit-default-sources.spec.ts | 39 ++ frontend/e2e/tests/olm/operator-hub.spec.ts | 108 ++++ .../tests/olm/operator-install-global.spec.ts | 157 ++++++ .../operator-install-single-namespace.spec.ts | 199 ++++++++ .../e2e/tests/olm/operator-uninstall.spec.ts | 141 ++++++ .../e2e/tests/olm/packageserver-tabs.spec.ts | 104 ++++ .../catalog-view/CatalogEmptyState.tsx | 7 +- .../tests/catalog-source-details.cy.ts | 107 ---- .../tests/deprecated-operator-warnings.cy.ts | 277 ----------- .../src/components/clusterserviceversion.tsx | 1 + .../modals/edit-default-sources-modal.tsx | 8 +- .../modals/uninstall-operator-modal.tsx | 8 +- .../registry-poll-interval-details.tsx | 2 + .../public/components/utils/details-item.tsx | 1 + .../public/components/utils/details-page.tsx | 2 +- frontend/public/components/utils/headings.tsx | 7 +- 40 files changed, 3274 insertions(+), 433 deletions(-) create mode 100644 frontend/e2e/pages/catalog-source-page.ts create mode 100644 frontend/e2e/pages/installed-operators-page.ts create mode 100644 frontend/e2e/pages/operand-page.ts create mode 100644 frontend/e2e/pages/operator-details-page.ts create mode 100644 frontend/e2e/pages/operator-hub-details-page.ts create mode 100644 frontend/e2e/pages/operator-install-page.ts create mode 100644 frontend/e2e/test-utils/cluster-cleanup.ts create mode 100644 frontend/e2e/test-utils/olm-cleanup.ts create mode 100644 frontend/e2e/test-utils/olm-test-cleanup.ts create mode 100644 frontend/e2e/test-utils/operator-cleanup.ts create mode 100644 frontend/e2e/test-utils/test-namespace.ts create mode 100644 frontend/e2e/tests/olm/catalog-source-details.spec.ts create mode 100644 frontend/e2e/tests/olm/descriptors.spec.ts create mode 100644 frontend/e2e/tests/olm/edit-default-sources.spec.ts create mode 100644 frontend/e2e/tests/olm/operator-hub.spec.ts create mode 100644 frontend/e2e/tests/olm/operator-install-global.spec.ts create mode 100644 frontend/e2e/tests/olm/operator-install-single-namespace.spec.ts create mode 100644 frontend/e2e/tests/olm/operator-uninstall.spec.ts create mode 100644 frontend/e2e/tests/olm/packageserver-tabs.spec.ts delete mode 100644 frontend/packages/operator-lifecycle-manager/integration-tests/tests/catalog-source-details.cy.ts delete mode 100644 frontend/packages/operator-lifecycle-manager/integration-tests/tests/deprecated-operator-warnings.cy.ts diff --git a/frontend/e2e/clients/kubernetes-client.ts b/frontend/e2e/clients/kubernetes-client.ts index 9d7bed68328..f420abeb8f9 100644 --- a/frontend/e2e/clients/kubernetes-client.ts +++ b/frontend/e2e/clients/kubernetes-client.ts @@ -279,6 +279,14 @@ export default class KubernetesClient { } } + getCurrentUser(): any { + try { + return this.kubeConfig.getCurrentUser(); + } catch { + return { name: 'idk' }; + } + } + async verifyAuthentication(): Promise { await this.k8sApi.listNamespace({ limit: 1 }); return true; @@ -560,6 +568,40 @@ export default class KubernetesClient { } } + async patchClusterCustomResource( + group: string, + version: string, + plural: string, + name: string, + patch: object[], + ): Promise { + await this.coApi.patchClusterCustomObject({ + group, + name, + plural, + version, + body: patch, + contentType: 'application/json-patch+json', + } as any); + } + + async getClusterCustomResource( + group: string, + version: string, + plural: string, + name: string, + ): Promise { + try { + const response = await this.coApi.getClusterCustomObject({ group, name, plural, version }); + return response; + } catch (err) { + if (isNotFound(err)) { + return null; + } + throw err; + } + } + async getCustomResource( group: string, version: string, @@ -577,6 +619,26 @@ export default class KubernetesClient { return response; } + async patchCustomResource( + group: string, + version: string, + namespace: string, + plural: string, + name: string, + patch: object[], + ): Promise { + const response = await this.coApi.patchNamespacedCustomObject({ + body: patch, + group, + name, + namespace, + plural, + version, + contentType: k8s.PatchStrategy.JsonPatch, + } as any); + return response; + } + async listCustomResources( group: string, version: string, @@ -596,6 +658,32 @@ export default class KubernetesClient { } } + async listClusterCustomResources( + group: string, + version: string, + plural: string, + ): Promise { + try { + const response = await this.coApi.listClusterCustomObject({ + group, + plural, + version, + }); + return (response as any)?.items || []; + } catch { + return []; + } + } + + async listNamespaces(): Promise { + try { + const response = await this.k8sApi.listNamespace(); + return (response?.items || []); + } catch { + return []; + } + } + async getPods(namespace: string): Promise { const response = await this.k8sApi.listNamespacedPod({ namespace }); return response.items || []; diff --git a/frontend/e2e/pages/catalog-page.ts b/frontend/e2e/pages/catalog-page.ts index 4e498a9e401..e89d7b5765a 100644 --- a/frontend/e2e/pages/catalog-page.ts +++ b/frontend/e2e/pages/catalog-page.ts @@ -4,16 +4,79 @@ import BasePage from './base-page'; export class CatalogPage extends BasePage { private readonly filterInput: Locator = this.page.getByPlaceholder('Filter by keyword'); + private readonly searchCatalogInput = this.page.getByTestId('search-catalog').locator('input'); + private readonly operatorTab = this.page.getByTestId('tab operator'); + private readonly clearFiltersButton = this.page.getByTestId('catalog-clear-filters'); + private readonly pageHeading = this.page.getByTestId('page-heading'); async navigateToCatalog(): Promise { await this.goTo('/catalog/all-namespaces'); await expect(this.filterInput).toBeVisible({ timeout: 60_000 }); } + async navigateToSoftwareCatalog(namespace: string): Promise { + await this.goTo(`/catalog/ns/${namespace}`); + await expect(this.pageHeading).toBeVisible({ timeout: 30_000 }); + } + async filterByKeyword(keyword: string): Promise { await this.filterInput.fill(keyword); } + async searchOperators(operatorName: string): Promise { + await this.searchCatalogInput.fill(operatorName); + } + + async clearSearchFilter(): Promise { + await this.searchCatalogInput.fill(''); + } + + async clickOperatorTab(): Promise { + await this.robustClick(this.operatorTab); + } + + async clickClearAllFilters(): Promise { + await this.robustClick(this.clearFiltersButton); + } + + async toggleSourceFilter(filterType: string): Promise { + const filterCheckbox = this.page.getByTestId(`source-${filterType}`); + await filterCheckbox.click(); + } + + async clickCategoryFilter(categoryId: string): Promise { + const categoryTab = this.page.locator(`[data-test="tab ${categoryId}"] > a`); + await this.robustClick(categoryTab); + } + + getCatalogTiles(): Locator { + return this.page.locator('.co-catalog-tile'); + } + + getFirstCatalogTile(): Locator { + return this.getCatalogTiles().first(); + } + + getFirstCatalogTileTitle(): Locator { + return this.getFirstCatalogTile().locator('.catalog-tile-pf-title'); + } + + getClearFiltersButton(): Locator { + return this.clearFiltersButton; + } + + getPageHeading(): Locator { + return this.pageHeading; + } + + getSearchInput(): Locator { + return this.searchCatalogInput; + } + + getSearchInputElement(): Locator { + return this.searchCatalogInput; + } + catalogItem(testId: string): Locator { return this.page.getByTestId(testId); } @@ -21,4 +84,18 @@ export class CatalogPage extends BasePage { catalogItemIcon(testId: string): Locator { return this.catalogItem(testId).locator('img.catalog-tile-pf-icon'); } + + /** + * Verify that catalog tiles contain expected title text + */ + async verifyTileContainsText(expectedText: string): Promise { + await expect(this.getFirstCatalogTileTitle()).toHaveText(expectedText); + } + + /** + * Verify that first tile title has changed from original text + */ + async verifyTileTextChanged(originalText: string): Promise { + await expect(this.getFirstCatalogTileTitle()).not.toHaveText(originalText); + } } diff --git a/frontend/e2e/pages/catalog-source-page.ts b/frontend/e2e/pages/catalog-source-page.ts new file mode 100644 index 00000000000..fa9e212d264 --- /dev/null +++ b/frontend/e2e/pages/catalog-source-page.ts @@ -0,0 +1,72 @@ +import type { Locator } from '@playwright/test'; + +import BasePage from './base-page'; + +export class CatalogSourcePage extends BasePage { + private readonly configurationTab = this.page.getByTestId('horizontal-link-Configuration'); + private readonly sourcesTab = this.page.getByTestId('horizontal-link-Sources'); + private readonly operatorsTab = this.page.getByTestId('horizontal-link-Operators'); + + private readonly packageManifestTable = this.page.getByTestId('PackageManifestTable'); + + private readonly registryPollIntervalDropdown = this.page.getByTestId( + 'registry-poll-interval-dropdown', + ); + private readonly registryPollIntervalModalTitle = this.page.getByTestId( + 'registry-poll-interval-modal-title', + ); + + async navigateToOperatorHubSources(): Promise { + await this.goTo('/settings/cluster'); + await this.navigateToTab(this.configurationTab); + await this.waitForLoadingComplete(); + const operatorHubLink = this.page.getByTestId('OperatorHub'); + await operatorHubLink.scrollIntoViewIfNeeded(); + await this.robustClick(operatorHubLink); + await this.navigateToTab(this.sourcesTab); + } + + async openCatalogSourceDetails(name: string): Promise { + await this.robustClick(this.page.getByTestId(name)); + } + + getSectionHeading(text: string): Locator { + return this.page.getByTestId(`section-heading-${text}`); + } + + getDetailsLabel(label: string): Locator { + return this.page.getByTestId(`details-item-label__${label}`); + } + + getDetailsValue(label: string): Locator { + return this.page.getByTestId(`details-item-value__${label}`); + } + + getPackageManifestTable(): Locator { + return this.packageManifestTable; + } + + getRegistryPollIntervalModalTitle(): Locator { + return this.registryPollIntervalModalTitle; + } + + async selectOperatorsTab(): Promise { + await this.navigateToTab(this.operatorsTab); + } + + async clickEditRegistryPollInterval(): Promise { + const editButton = this.page.getByTestId( + 'Registry poll interval-details-item__edit-button', + ); + await this.robustClick(editButton); + } + + async selectPollInterval(interval: string): Promise { + await this.robustClick(this.registryPollIntervalDropdown); + await this.robustClick(this.page.getByTestId(`dropdown-menu-${interval}`)); + } + + async submitPollIntervalModal(): Promise { + await this.robustClick(this.page.getByTestId('confirm-action')); + } +} diff --git a/frontend/e2e/pages/details-page.ts b/frontend/e2e/pages/details-page.ts index 6274343a6c2..63f3d3f03a4 100644 --- a/frontend/e2e/pages/details-page.ts +++ b/frontend/e2e/pages/details-page.ts @@ -72,4 +72,17 @@ export class DetailsPage extends BasePage { const kebabButton = resourceRow.getByTestId('kebab-button'); await this.robustClick(kebabButton); } + + getSectionHeader(text: string): Locator { + return this.page.getByTestId(`section-heading-${text}`); + } + + getEmptyState(): Locator { + return this.page.getByTestId('console-empty-state'); + } + + async navigateToDetailsUrl(url: string): Promise { + await this.goTo(url); + await this.waitForPageLoad(); + } } diff --git a/frontend/e2e/pages/installed-operators-page.ts b/frontend/e2e/pages/installed-operators-page.ts new file mode 100644 index 00000000000..96b61aa9e0a --- /dev/null +++ b/frontend/e2e/pages/installed-operators-page.ts @@ -0,0 +1,215 @@ +import type { Locator } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import BasePage from './base-page'; +import { Navigation } from './navigation'; + +export class InstalledOperatorsPage extends BasePage { + private readonly navigation = new Navigation(this.page); + private readonly pageHeading = this.page.getByTestId('page-heading'); + private readonly nameFilterInput = this.page.getByTestId('name-filter-input'); + private readonly statusText = this.page.getByTestId('status-text'); + + /** + * Navigate to Installed Operators page + */ + async navigateToInstalledOperators(): Promise { + await this.navigation.clickNavLink('Ecosystem', 'Installed Operators'); + await expect(this.pageHeading).toContainText('Installed Operators'); + } + + /** + * Filter operators by name + */ + async filterByName(operatorName: string): Promise { + await this.nameFilterInput.focus(); + await this.nameFilterInput.clear(); + await this.nameFilterInput.fill(operatorName); + } + + /** + * Get operator row by name + */ + getOperatorRow(operatorName: string): Locator { + return this.page.getByTestId(`operator-row-${operatorName}`); + } + + /** + * Get operator status element + */ + getOperatorStatus(): Locator { + return this.statusText; + } + + /** + * Click on operator row to navigate to details + */ + async clickOperatorRow(operatorName: string, operatorURLName: string): Promise { + // Get h1 child of the operator row (clicking the directly is flaky, hitting the

works) + const operatorLink = this.getOperatorRow(operatorName).locator('h1'); + + // 1. Ensure the link is visible first + await expect(operatorLink).toBeVisible({ timeout: 30_000 }); + + // 2. Small delay to ensure any animations/transitions are complete + await this.page.waitForTimeout(500); + + await operatorLink.click(); + } + + /** + * Verify operator installation succeeded + */ + async verifyOperatorInstallationSucceeded(operatorName: string): Promise { + await this.navigateToInstalledOperators(); + await this.filterByName(operatorName); + + // Verify operator row exists (with extended timeout for installation) + const operatorRow = this.getOperatorRow(operatorName); + await expect(operatorRow).toBeVisible({ timeout: 60_000 }); + + // Debug the status element by polling its text content + const statusElement = this.page.getByTestId('status-text'); + + console.log(`Waiting for ${operatorName} operator status to be 'Succeeded'...`); + + // Poll the status text every 5 seconds and log what we see + let attempts = 0; + const maxAttempts = 36; // 3 minutes worth of 5-second polls + + while (attempts < maxAttempts) { + let currentText: string | null = null; + try { + currentText = await statusElement.textContent({ timeout: 5000 }); + console.log(`Attempt ${attempts + 1}: Status text is "${currentText}"`); + } catch (error) { + console.log(`Attempt ${attempts + 1}: Could not read status text: ${error.message}`); + } + + if (currentText?.includes('Succeeded')) { + console.log('✅ Found "Succeeded" in status text!'); + return; // Success! + } + + if (currentText?.includes('Failed')) { + throw new Error(`Operator installation failed. Status: ${currentText}`); + } + + attempts++; + await this.page.waitForTimeout(5000); // Wait 5 seconds between polls + } + + throw new Error(`Timeout waiting for operator status to be 'Succeeded' after ${maxAttempts * 5} seconds`); + } + + /** + * Navigate to operator details page + */ + async navigateToOperatorDetails(operatorName: string, operatorURLName: string, namespace: string = 'openshift-operators'): Promise { + await this.navigateToInstalledOperators(); + + // Select namespace if not openshift-operators + await this.selectNamespace(namespace); + + await this.filterByName(operatorName); + + // Wait for debounce to complete before clicking (filter-toolbar.tsx uses 250ms debounce) + await this.page.waitForFunction(() => { + const input = document.querySelector('[data-test="name-filter-input"]') as HTMLInputElement; + return input && !input.disabled; + }); + + // Wait for the operator row to be visible + await expect(this.getOperatorRow(operatorName)).toBeVisible({ timeout: 30_000 }); + + // Additional wait to ensure the table row is stable and ready for interaction + await this.page.waitForFunction( + (name) => { + const row = document.querySelector(`[data-test="operator-row-${name}"]`); + if (!row) return false; + // Check that row is fully rendered and stable + const style = window.getComputedStyle(row); + return style.opacity === '1' && style.visibility === 'visible' && !row.hasAttribute('aria-busy'); + }, + operatorName, + { timeout: 10_000 } + ); + + await this.clickOperatorRow(operatorName, operatorURLName); + + // Wait for navigation to complete by checking for a page element that only exists on the CSV details page + // This is more reliable than just waiting for URL or skeleton changes + await expect(this.page.getByTestId('resource-summary')).toBeVisible({ + timeout: 60_000, + }); + + // Now wait for the Details tab to be visible + await expect(this.page.getByTestId('horizontal-link-Details')).toBeVisible({ timeout: 30_000 }); + } + + /** + * Verify operator no longer exists + */ + async verifyOperatorNotExists(operatorName: string): Promise { + await this.navigateToInstalledOperators(); + await expect(this.getOperatorRow(operatorName)).not.toBeAttached(); + } + + /** + * Verify no operators exist in the current namespace (empty state) + */ + async verifyNoOperatorsInstalled(): Promise { + await this.navigateToInstalledOperators(); + + // Wait for loading to complete + await expect(this.page.locator('.loading-skeleton--table')).not.toBeAttached({ timeout: 30_000 }); + + // Verify empty state appears + const emptyState = this.page.getByTestId('console-empty-state'); + await expect(emptyState).toContainText('No Operators found'); + } + + /** + * Verify operator is not installed in specific namespace (for isolation testing) + */ + async verifyOperatorNotInstalledInNamespace(operatorName: string, namespace: string): Promise { + await this.navigateToInstalledOperators(); + await this.selectNamespace(namespace); + + await this.filterByName(operatorName); + await expect(this.getOperatorRow(operatorName)).not.toBeAttached(); + } + + /** + * Select namespace using project dropdown + */ + async selectNamespace(namespace: string): Promise { + const namespaceDropdownButton = this.page.getByTestId('namespace-bar-dropdown').locator('button'); + await this.robustClick(namespaceDropdownButton); + + // Check if showSystemSwitch is checked, if not, check it + const showSystemSwitch = this.page.getByTestId('showSystemSwitch'); + const isChecked = await showSystemSwitch.isChecked(); + if (!isChecked) { + await showSystemSwitch.click(); + } + + // Filter the namespace list to make the target namespace visible + const textFilter = this.page.getByTestId('dropdown-text-filter'); + await textFilter.fill(namespace); + + // Select the dropdown menu item that contains our namespace text + const namespaceOption = this.page.getByTestId('dropdown-menu-item-link').filter({ hasText: namespace }).first(); + await this.robustClick(namespaceOption); + + await expect(this.page.getByTestId('namespace-bar-dropdown')).toContainText(namespace); + } + + getPageHeading(): Locator { + return this.pageHeading; + } + + getNameFilterInput(): Locator { + return this.nameFilterInput; + } +} diff --git a/frontend/e2e/pages/operand-page.ts b/frontend/e2e/pages/operand-page.ts new file mode 100644 index 00000000000..ac60c767be3 --- /dev/null +++ b/frontend/e2e/pages/operand-page.ts @@ -0,0 +1,80 @@ +import type { Locator } from '@playwright/test'; + +import BasePage from './base-page'; + +export class OperandPage extends BasePage { + private readonly createButton = this.page.getByTestId('item-create'); + private readonly createFormSubmit = this.page.getByTestId('create-dynamic-form'); + private readonly operandDetailsSection = this.page.getByTestId( + 'operand-details__section--info', + ).first(); + private readonly resourceTitle = this.page.getByTestId('resource-title'); + + async navigateTo(url: string): Promise { + await this.goTo(url); + } + + getOperandLink(name: string): Locator { + return this.page.getByTestId(name); + } + + async clickOperandLink(name: string): Promise { + await this.robustClick(this.getOperandLink(name), { force: true }); + } + + getResourceTitle(): Locator { + return this.resourceTitle; + } + + getDetailsItemLabel(label: string): Locator { + return this.page.getByTestId(`details-item-label__${label}`).first(); + } + + getOperandDetailsSection(): Locator { + return this.operandDetailsSection; + } + + async clickCreate(): Promise { + await this.robustClick(this.createButton, { force: true }); + } + + getFormHeading(): Locator { + return this.page.getByTestId('page-heading').locator('h1'); + } + + getFormFieldElement(id: string): Locator { + return this.page.locator(`#${id}_field`); + } + + getFormFieldLabel(id: string): Locator { + return this.page.locator(`[for="${id}"]`); + } + + getFormFieldInput(id: string): Locator { + return this.page.locator(`#${id}`); + } + + getFormFieldGroup(id: string): Locator { + return this.page.locator(`#${id}_field-group`); + } + + getFormFieldGroupToggle(id: string): Locator { + return this.page.locator(`#${id}_accordion-toggle`); + } + + async toggleFieldGroup(id: string): Promise { + await this.robustClick(this.getFormFieldGroupToggle(id)); + } + + getTagItemContent(fieldId: string): Locator { + return this.page.locator(`#${fieldId}_field .tag-item-content`); + } + + async fillNameField(id: string, value: string): Promise { + await this.getFormFieldInput(id).fill(value); + } + + async submitCreateForm(): Promise { + await this.robustClick(this.createFormSubmit); + } +} diff --git a/frontend/e2e/pages/operator-details-page.ts b/frontend/e2e/pages/operator-details-page.ts new file mode 100644 index 00000000000..b81eae4d2d2 --- /dev/null +++ b/frontend/e2e/pages/operator-details-page.ts @@ -0,0 +1,338 @@ +import type { Locator } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import BasePage from './base-page'; +import { DetailsPage } from './details-page'; +import { ModalPage } from './modal-page'; + +export interface TestOperandProps { + name: string; + group: string; + version: string; + kind: string; + createActionID?: string; + exampleName: string; +} + +export class OperatorDetailsPage extends BasePage { + private readonly detailsPage = new DetailsPage(this.page); + private readonly modalPage = new ModalPage(this.page); + private readonly createItemButton = this.page.getByTestId('item-create'); + private readonly nameInput = this.page.locator('[id="root_metadata_name"]'); + + /** + * Verify operator details page sections exist + */ + async verifyDetailsPageSections(): Promise { + await expect(this.getSectionHeading('Provided APIs')).toBeVisible({ timeout: 60_000 }); + await expect(this.getSectionHeading('ClusterServiceVersion details')).toBeVisible({ timeout: 30_000 }); + await expect(this.page.getByTestId('resource-summary')).toBeVisible({ timeout: 30_000 }); + } + + /** + * Navigate to operand instances tab + */ + async navigateToOperandTab(operandName: string, isGlobal: boolean = true): Promise { + // Ensure we're on the operator details page by checking for operator-specific tabs + await expect(this.page.getByTestId('horizontal-link-Details')).toBeVisible({ timeout: 30_000 }); + + if (isGlobal) { + // Wait for the "All instances" tab to be available before trying to click it + await expect(this.page.getByTestId('horizontal-link-All instances')).toBeVisible({ timeout: 30_000 }); + await this.detailsPage.selectTab('All instances'); + } else { + await this.detailsPage.selectTab(operandName); + } + } + + /** + * Create operand instance + */ + async createOperand(testOperand: TestOperandProps, isGlobal: boolean = true): Promise { + const { exampleName, createActionID } = testOperand; + + await this.navigateToOperandTab(testOperand.name, isGlobal); + + // Verify operand doesn't already exist + await expect(this.getOperandLink(exampleName)).not.toBeAttached(); + + // Click create button + await this.robustClick(this.createItemButton); + + // If specific create action ID is provided, wait for dropdown and click it + if (createActionID) { + // Wait for the dropdown item to be visible before clicking + await expect(this.page.getByTestId(createActionID)).toBeVisible({ timeout: 30_000 }); + await this.robustClick(this.page.getByTestId(createActionID)); + } + + // Verify we're on the create form + await expect(this.page).toHaveURL(/~new/); + + // Fill in the name + await expect(this.nameInput).toBeEnabled(); + await this.nameInput.clear(); + await this.nameInput.fill(exampleName); + + // Submit the form + await this.clickSubmitButton(); + + // Wait for form submission and redirect + await expect(this.page).not.toHaveURL(/~new/, { timeout: 60_000 }); + } + + /** + * Verify operand exists + */ + async verifyOperandExists(testOperand: TestOperandProps, isGlobal: boolean = true): Promise { + const { exampleName } = testOperand; + + await this.navigateToOperandTab(testOperand.name, isGlobal); + await expect(this.page.getByTestId(exampleName)).toBeVisible(); + + // Navigate to operand details + await this.page.getByTestId(exampleName).click(); + await expect(this.page).toHaveURL(new RegExp(`${exampleName}$`)); + } + + /** + * Delete operand + */ + async deleteOperand(testOperand: TestOperandProps, isGlobal: boolean = true): Promise { + + // Double check that we are on the example operand page + await expect(this.page).toHaveURL(new RegExp(`${testOperand.exampleName}$`)); + // const { kind, exampleName } = testOperand; + + // First, ensure we're back on the operator details page (not operand details page) + // Navigate back using breadcrumb or go back to operator details page + // const breadcrumbLink = this.detailsPage.getBreadcrumb(1); // Assuming operator details is breadcrumb 1 + // if (await breadcrumbLink.count() > 0) { + // await this.robustClick(breadcrumbLink); + // } + + // await this.navigateToOperandTab(testOperand.name, isGlobal); + + // // Navigate to operand details page + // await this.robustClick(this.getOperandLink(exampleName)); + + // Delete the operand + await this.detailsPage.clickPageAction(`Delete ${testOperand.kind}`); + await this.modalPage.waitForOpen(); + await this.modalPage.submit(); + await this.modalPage.waitForClosed(); + } + + /** + * Verify operand no longer exists + */ + async verifyOperandNotExists(testOperand: TestOperandProps, isGlobal: boolean = true): Promise { + const { exampleName } = testOperand; + + await this.navigateToOperandTab(testOperand.name, isGlobal); + await expect(this.page.getByTestId(exampleName)).not.toBeAttached(); + } + + /** + * Create operand instance from the current tab (no navigation) + */ + async createOperandFromTab(testOperand: TestOperandProps): Promise { + const { exampleName, createActionID } = testOperand; + + // Verify operand doesn't already exist + await expect(this.getOperandLink(exampleName)).not.toBeAttached(); + + // Click create button + await this.robustClick(this.createItemButton); + + // If specific create action ID is provided, wait for dropdown and click it + if (createActionID) { + // Wait for the dropdown item to be visible before clicking + await expect(this.page.getByTestId(createActionID)).toBeVisible({ timeout: 30_000 }); + await this.robustClick(this.page.getByTestId(createActionID)); + } + + // Verify we're on the create form + await expect(this.page).toHaveURL(/~new/); + + // Fill in the name + await expect(this.nameInput).toBeEnabled(); + await this.nameInput.clear(); + await this.nameInput.fill(exampleName); + + // Submit the form + await this.clickSubmitButton(); + + // Wait for form submission and redirect + await expect(this.page).not.toHaveURL(/~new/, { timeout: 60_000 }); + } + + /** + * Click operand link (no navigation, just click) + */ + async clickOperandLink(exampleName: string): Promise { + await this.robustClick(this.getOperandLink(exampleName)); + } + + /** + * Delete current operand (assumes we're on operand details page) + */ + async deleteCurrentOperand(kind: string): Promise { + await this.detailsPage.clickPageAction(`Delete ${kind}`); + await this.modalPage.waitForOpen(); + await this.modalPage.submit(); + await this.modalPage.waitForClosed(); + } + + /** + * Verify operand no longer exists on current tab (no navigation) + */ + async verifyOperandNotExistsOnCurrentTab(exampleName: string): Promise { + await expect(this.page.getByTestId(exampleName)).not.toBeAttached(); + } + + /** + * Uninstall operator + * @param submit - Whether to submit the uninstall or just open the modal (default: true) + */ + async uninstallOperator(submit: boolean = true): Promise { + await this.detailsPage.clickPageAction('Uninstall Operator'); + await this.modalPage.waitForOpen(); + await expect(this.modalPage.getModalTitle()).toContainText('Uninstall Operator?'); + + // Wait for loading skeleton to disappear + await expect(this.page.locator('.loading-skeleton--table')).not.toBeAttached({ timeout: 120_000 }); + + if (submit) { + await this.modalPage.submit(); + await this.modalPage.waitForClosed(); + } + } + + /** + * Uninstall operator with all operands + */ + async uninstallOperatorWithOperands(): Promise { + await this.detailsPage.clickPageAction('Uninstall Operator'); + await this.modalPage.waitForOpen(); + await expect(this.modalPage.getModalTitle()).toContainText('Uninstall Operator?'); + + // Wait for loading skeleton to disappear + await expect(this.page.locator('.loading-skeleton--table')).not.toBeAttached({ timeout: 120_000 }); + + // Check delete all operands option + await this.page.getByTestId('delete-all-operands').click(); + + await this.modalPage.submit(); + await this.modalPage.waitForClosed(); + } + + /** + * Uninstall operator with API error interception + */ + async uninstallOperatorWithAPIError(errorType: 'cannot-load-operands' | 'error-deleting-operands'): Promise { + // Set up API interception based on error type + if (errorType === 'cannot-load-operands') { + await this.page.route('**/apis/operators.coreos.com/v1alpha1/namespaces/*/clusterserviceversions/*/instances**', route => { + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ + kind: 'Status', + apiVersion: 'v1', + metadata: {}, + status: 'Failure', + message: 'Internal server error', + reason: 'InternalError', + code: 500 + }) + }); + }); + } else if (errorType === 'error-deleting-operands') { + // Intercept DELETE requests for operands + await this.page.route('**/apis/*/v*/namespaces/*/devworkspaces/**', route => { + if (route.request().method() === 'DELETE') { + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ + kind: 'Status', + apiVersion: 'v1', + metadata: {}, + status: 'Failure', + message: 'Unable to delete operand', + reason: 'InternalError', + code: 500 + }) + }); + } else { + route.continue(); + } + }); + } + + await this.detailsPage.clickPageAction('Uninstall Operator'); + await this.modalPage.waitForOpen(); + await expect(this.modalPage.getModalTitle()).toContainText('Uninstall Operator?'); + + if (errorType === 'cannot-load-operands') { + // Wait for error alert to appear + await expect(this.page.getByTestId('alert-danger')).toContainText('Cannot load Operands'); + } else if (errorType === 'error-deleting-operands') { + // Check delete all operands option first to trigger the delete requests + await this.page.getByTestId('delete-all-operands').click(); + + // Wait for error alert to appear + await expect(this.page.getByTestId('alert-danger')).toContainText('Error Deleting Operands'); + } + } + + /** + * Verify alert message appears in uninstall modal + */ + async verifyUninstallAlert(expectedText: string): Promise { + // const alert = this.page.getByTestId(`alert-${alertType}`); + const modal = this.page.getByRole('dialog'); + // const modalTitle = this.page.getByTestId('modal-title') + await expect(modal).toBeVisible(); + await expect(modal).toContainText(expectedText); + } + + /** + * Cancel uninstall modal + */ + async cancelUninstall(): Promise { + await this.modalPage.cancel(); + await this.modalPage.waitForClosed(); + } + + /** + * Get section heading locator + */ + getSectionHeading(text: string): Locator { + return this.page.locator(`[data-test-section-heading="${text}"]`); + } + + /** + * Get operand link locator + */ + getOperandLink(operandName: string): Locator { + return this.page.getByTestId(operandName); + } + + /** + * Click submit button + */ + private async clickSubmitButton(): Promise { + const submitButton = this.page.locator('[data-test="confirm-action"], .pf-v6-c-button.pf-m-primary[type="submit"]'); + await this.robustClick(submitButton); + } + + getCreateItemButton(): Locator { + return this.createItemButton; + } + + getNameInput(): Locator { + return this.nameInput; + } +} diff --git a/frontend/e2e/pages/operator-hub-details-page.ts b/frontend/e2e/pages/operator-hub-details-page.ts new file mode 100644 index 00000000000..d2a092f904e --- /dev/null +++ b/frontend/e2e/pages/operator-hub-details-page.ts @@ -0,0 +1,100 @@ +import type { Locator } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import BasePage from './base-page'; +import { ClusterSettingsPage } from './cluster-settings-page'; +import { ModalPage } from './modal-page'; + +export class OperatorHubDetailsPage extends BasePage { + private readonly pageHeading = this.page.getByTestId('page-heading'); + private readonly editDefaultSourcesButton = this.page.getByTestId( + 'Default sources-details-item__edit-button', + ); + private readonly modalPage = new ModalPage(this.page); + private readonly clusterSettingsPage = new ClusterSettingsPage(this.page); + + /** + * Navigate to OperatorHub details page via cluster settings configuration + */ + async navigateToOperatorHub(): Promise { + await this.clusterSettingsPage.navigateToConfiguration(); + await this.page.getByTestId('OperatorHub').click(); + await expect(this.pageHeading).toBeVisible({ timeout: 30_000 }); + } + + /** + * Get page heading locator + */ + getPageHeading(): Locator { + return this.pageHeading; + } + + /** + * Click the edit button for default sources + */ + async openEditDefaultSourcesModal(): Promise { + await this.robustClick(this.editDefaultSourcesButton); + await this.modalPage.waitForOpen(); + } + + /** + * Get the status locator for a specific source + */ + getSourceStatus(sourceName: string): Locator { + return this.page.getByTestId(`status_${sourceName}`); + } + + /** + * Toggle a default source in the edit modal + */ + async toggleDefaultSource(sourceName: string): Promise { + const checkbox = this.page.getByTestId(`${sourceName}__checkbox`); + await checkbox.click(); + } + + /** + * Submit the edit default sources modal + */ + async submitModal(): Promise { + await this.modalPage.submit(); + await this.modalPage.waitForClosed(); + } + + /** + * Get modal page instance for modal-specific operations + */ + getModal(): ModalPage { + return this.modalPage; + } + + /** + * Verify section heading exists + */ + async verifySectionHeading(heading: string): Promise { + const sectionHeading = this.page.locator(`[data-test-section-heading="${heading}"]`); + await expect(sectionHeading).toBeVisible(); + } + + /** + * Complete flow: toggle source, verify status, and toggle back + */ + async toggleSourceAndVerify(sourceName: string, expectedStatus1: string, expectedStatus2: string): Promise { + // First toggle + await this.openEditDefaultSourcesModal(); + await expect(this.modalPage.getModalTitle()).toContainText('Edit default sources'); + await this.toggleDefaultSource(sourceName); + await this.submitModal(); + + // Verify status change + await expect(this.getSourceStatus(sourceName)).toHaveText(expectedStatus1); + + // Toggle back + await this.openEditDefaultSourcesModal(); + await expect(this.modalPage.getModalTitle()).toContainText('Edit default sources'); + await this.toggleDefaultSource(sourceName); + await this.submitModal(); + + // Verify status back to original + await expect(this.getSourceStatus(sourceName)).toHaveText(expectedStatus2); + } +} \ No newline at end of file diff --git a/frontend/e2e/pages/operator-install-page.ts b/frontend/e2e/pages/operator-install-page.ts new file mode 100644 index 00000000000..5bc5a8b1c10 --- /dev/null +++ b/frontend/e2e/pages/operator-install-page.ts @@ -0,0 +1,172 @@ +import type { Locator } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import BasePage from './base-page'; +import { CatalogPage } from './catalog-page'; + +export class OperatorInstallPage extends BasePage { + private readonly catalogPage = new CatalogPage(this.page); + private readonly installButton = this.page.getByTestId('catalog-details-modal-cta'); + private readonly channelSelect = this.page.getByTestId('operator-channel-select-toggle'); + private readonly versionSelect = this.page.getByTestId('operator-version-select-toggle'); + private readonly allNamespacesRadio = this.page.getByTestId('All namespaces on the cluster-radio-input'); + private readonly specificNamespaceRadio = this.page.getByTestId('A specific namespace on the cluster-radio-input'); + private readonly operatorRecommendedRadio = this.page.getByTestId('Operator recommended Namespace:-radio-input'); + private readonly selectNamespaceRadio = this.page.getByTestId('Select a Namespace-radio-input'); + private readonly namespaceDropdown = this.page.getByTestId('dropdown-selectbox'); + private readonly searchInput = this.page.getByTestId('console-select-search-input').locator('input'); + private readonly installOperatorButton = this.page.getByTestId('install-operator'); + private readonly viewInstalledOperatorsBtn = this.page.getByTestId('view-installed-operators-btn'); + + /** + * Install an operator globally in openshift-operators + */ + async installOperatorGlobally(operatorName: string, operatorCardTestID: string): Promise { + await this.goTo('/catalog/all-namespaces'); + + await this.catalogPage.clickOperatorTab(); + await this.catalogPage.searchOperators(operatorName); + + // Verify operator exists before clicking + const operatorCard = this.page.getByTestId(operatorCardTestID); + await expect(operatorCard).toBeVisible({ timeout: 30_000 }); + await this.robustClick(operatorCard); + + // Wait for install button and verify it has href + await expect(this.installButton).toBeVisible(); + await expect(this.installButton).toHaveAttribute('href'); + await this.robustClick(this.installButton); + + // Verify installation form elements + await expect(this.channelSelect).toBeVisible(); + await expect(this.versionSelect).toBeVisible(); + + // Verify global installation is selected by default + await expect(this.allNamespacesRadio).toBeChecked({ timeout: 60_000 }); + + // Install the operator + await this.robustClick(this.installOperatorButton); + + // Verify installation started + await expect(this.viewInstalledOperatorsBtn).toContainText('View installed Operators in Namespace'); + await this.robustClick(this.viewInstalledOperatorsBtn); + } + + /** + * Install operator in specific namespace + */ + async installOperatorInNamespace( + operatorName: string, + operatorCardTestID: string, + namespace: string, + useOperatorRecommended: boolean = false, + ): Promise { + await this.goTo('/catalog/all-namespaces'); + + await this.catalogPage.clickOperatorTab(); + await this.catalogPage.searchOperators(operatorName); + + // Verify operator exists before clicking + const operatorCard = this.page.getByTestId(operatorCardTestID); + await expect(operatorCard).toBeVisible({ timeout: 30_000 }); + await this.robustClick(operatorCard); + + // Wait for install button and verify it has href + await expect(this.installButton).toBeVisible(); + await expect(this.installButton).toHaveAttribute('href'); + await this.robustClick(this.installButton); + + // Configure for specific namespace installation + await this.specificNamespaceRadio.check({ timeout: 60_000 }); + + if (useOperatorRecommended) { + await this.operatorRecommendedRadio.check(); + } else { + // Check if select namespace radio exists + if (await this.selectNamespaceRadio.count() > 0) { + await this.selectNamespaceRadio.check(); + } + + // Select the namespace + await this.robustClick(this.namespaceDropdown); + await this.searchInput.fill(namespace); + await this.robustClick(this.page.locator(`[data-test-dropdown-menu="${namespace}-Project"]`)); + await expect(this.namespaceDropdown).toContainText(namespace); + } + + // Install the operator + await this.robustClick(this.installOperatorButton); + + // Verify installation started and navigate to installed operators + await expect(this.viewInstalledOperatorsBtn).toContainText('View installed Operators in Namespace'); + await this.robustClick(this.viewInstalledOperatorsBtn); + } + + /** + * Install operator in a new namespace created through the UI + */ + async installOperatorInNewNamespace( + operatorName: string, + operatorCardTestID: string, + namespace: string, + ): Promise { + await this.goTo('/catalog/all-namespaces'); + + await this.catalogPage.clickOperatorTab(); + await this.catalogPage.searchOperators(operatorName); + + // Verify operator exists before clicking + const operatorCard = this.page.getByTestId(operatorCardTestID); + await expect(operatorCard).toBeVisible({ timeout: 30_000 }); + await this.robustClick(operatorCard); + + // Wait for install button and verify it has href + await expect(this.installButton).toBeVisible(); + await expect(this.installButton).toHaveAttribute('href'); + await this.robustClick(this.installButton); + + // Configure for specific namespace installation + await this.specificNamespaceRadio.check({ timeout: 60_000 }); + + // Check if select namespace radio exists + if (await this.selectNamespaceRadio.count() > 0) { + await this.selectNamespaceRadio.check(); + } + + // Create new namespace through UI + await this.robustClick(this.namespaceDropdown); + await this.robustClick(this.page.locator('[data-test-dropdown-menu^="Create_"]')); + + // Fill in the namespace name in the modal + await expect(this.page.getByTestId('input-name')).toBeVisible(); + await this.page.getByTestId('input-name').fill(namespace); + await this.robustClick(this.page.getByTestId('confirm-action')); + + // Wait for modal to close and namespace to be selected + await expect(this.page.getByRole('dialog')).toBeHidden(); + await expect(this.namespaceDropdown).toContainText(namespace); + + // Install the operator + await this.robustClick(this.installOperatorButton); + + // Verify installation started and navigate to installed operators + await expect(this.viewInstalledOperatorsBtn).toContainText('View installed Operators in Namespace'); + await this.robustClick(this.viewInstalledOperatorsBtn); + } + + getChannelSelect(): Locator { + return this.channelSelect; + } + + getVersionSelect(): Locator { + return this.versionSelect; + } + + getInstallButton(): Locator { + return this.installButton; + } + + getViewInstalledOperatorsButton(): Locator { + return this.viewInstalledOperatorsBtn; + } +} \ No newline at end of file diff --git a/frontend/e2e/pages/overview-page.ts b/frontend/e2e/pages/overview-page.ts index d18e8fe604f..09a996c4c9f 100644 --- a/frontend/e2e/pages/overview-page.ts +++ b/frontend/e2e/pages/overview-page.ts @@ -59,6 +59,7 @@ export class OverviewPage extends BasePage { await expect(this.listView).toBeVisible({ timeout: 15_000 }); } catch { await this.retryOnError(); + await expect(this.listView).toBeVisible({ timeout: 30_000 }); } } diff --git a/frontend/e2e/pages/yaml-editor-page.ts b/frontend/e2e/pages/yaml-editor-page.ts index ce6dfe94d92..93274164fde 100644 --- a/frontend/e2e/pages/yaml-editor-page.ts +++ b/frontend/e2e/pages/yaml-editor-page.ts @@ -117,4 +117,16 @@ export class YamlEditorPage extends BasePage { }); await this.robustClick(viewDetailsButton); } + + async getEditorContent(): Promise { + return this.page.evaluate(() => { + const models = (window as any).monaco.editor.getModels(); + return models[0]?.getValue() || ''; + }); + } + + async navigateToYamlUrl(url: string): Promise { + await this.goTo(url); + await this.waitForEditorReady(); + } } diff --git a/frontend/e2e/test-utils/cluster-cleanup.ts b/frontend/e2e/test-utils/cluster-cleanup.ts new file mode 100644 index 00000000000..2468b4d7c6d --- /dev/null +++ b/frontend/e2e/test-utils/cluster-cleanup.ts @@ -0,0 +1,208 @@ +/** + * Comprehensive cluster cleanup utility for removing orphaned test resources + * Run this when you have leftover operator installations from interrupted tests + */ + +export interface ClusterCleanupOptions { + dryRun?: boolean; + targetOperator?: string; + olderThanMinutes?: number; +} + +/** + * Clean up orphaned test namespaces and operator installations + */ +export async function cleanupClusterTestResources(k8sClient: any, options: ClusterCleanupOptions = {}): Promise { + const { dryRun = false, targetOperator = 'datagrid', olderThanMinutes = 60 } = options; + + console.log(`\n=== CLUSTER CLEANUP ${dryRun ? '(DRY RUN)' : ''} ===`); + console.log(`Target operator: ${targetOperator}`); + console.log(`Older than: ${olderThanMinutes} minutes`); + + const cutoffTime = new Date(Date.now() - (olderThanMinutes * 60 * 1000)); + + try { + // 1. Find all test namespaces + // Note: KubernetesClient doesn't expose listNamespace, so we'll use kubectl directly + // This is a limitation of the current client implementation + console.log('⚠️ KubernetesClient API limitation: using kubectl for namespace listing'); + + // For now, clean up known test namespaces by pattern + const { exec } = require('child_process'); + const { promisify } = require('util'); + const execPromise = promisify(exec); + + const { stdout } = await execPromise('kubectl get namespaces --output=json'); + const namespacesData = JSON.parse(stdout); + const namespaces = namespacesData; + const testNamespaces = namespaces.items?.filter((ns: any) => + ns.metadata.name.startsWith('test-') || ns.metadata.name.startsWith('test-operator-') + ) || []; + + console.log(`\nFound ${testNamespaces.length} test namespaces:`); + testNamespaces.forEach((ns: any) => { + const createdAt = new Date(ns.metadata.creationTimestamp); + const isOld = createdAt < cutoffTime; + console.log(` - ${ns.metadata.name} (created: ${createdAt.toISOString()}) ${isOld ? '⚠️ OLD' : '✅ recent'}`); + }); + + // 2. Clean up operator resources in old test namespaces + const oldTestNamespaces = testNamespaces.filter((ns: any) => { + const createdAt = new Date(ns.metadata.creationTimestamp); + return createdAt < cutoffTime; + }); + + for (const namespace of oldTestNamespaces) { + const namespaceName = namespace.metadata.name; + console.log(`\n--- Cleaning namespace: ${namespaceName} ---`); + + // Clean CSVs + try { + const csvs = await k8sClient.listCustomResources( + 'operators.coreos.com', + 'v1alpha1', + namespaceName, + 'clusterserviceversions' + ); + + const targetCSVs = (csvs || []).filter((csv: any) => + csv.metadata.name?.includes(targetOperator) + ); + + console.log(`Found ${targetCSVs.length} ${targetOperator} CSVs in ${namespaceName}`); + + for (const csv of targetCSVs) { + console.log(` ${dryRun ? 'Would delete' : 'Deleting'} CSV: ${csv.metadata.name}`); + if (!dryRun) { + await k8sClient.deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + namespaceName, + 'clusterserviceversions', + csv.metadata.name + ); + } + } + } catch (error) { + console.log(` Error checking CSVs in ${namespaceName}: ${error.message}`); + } + + // Clean Subscriptions + try { + const subscriptions = await k8sClient.listCustomResources( + 'operators.coreos.com', + 'v1alpha1', + namespaceName, + 'subscriptions' + ); + + const targetSubs = (subscriptions || []).filter((sub: any) => + sub.metadata.name?.includes(targetOperator) || sub.spec?.name?.includes(targetOperator) + ); + + console.log(`Found ${targetSubs.length} ${targetOperator} subscriptions in ${namespaceName}`); + + for (const sub of targetSubs) { + console.log(` ${dryRun ? 'Would delete' : 'Deleting'} subscription: ${sub.metadata.name}`); + if (!dryRun) { + await k8sClient.deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + namespaceName, + 'subscriptions', + sub.metadata.name + ); + } + } + } catch (error) { + console.log(` Error checking subscriptions in ${namespaceName}: ${error.message}`); + } + + // Clean InstallPlans + try { + const installPlans = await k8sClient.listCustomResources( + 'operators.coreos.com', + 'v1alpha1', + namespaceName, + 'installplans' + ); + + const targetIPs = (installPlans || []).filter((ip: any) => { + const csvNames = ip.spec?.clusterServiceVersionNames || []; + return csvNames.some((csvName: string) => csvName.includes(targetOperator)); + }); + + console.log(`Found ${targetIPs.length} ${targetOperator} install plans in ${namespaceName}`); + + for (const ip of targetIPs) { + console.log(` ${dryRun ? 'Would delete' : 'Deleting'} InstallPlan: ${ip.metadata.name}`); + if (!dryRun) { + await k8sClient.deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + namespaceName, + 'installplans', + ip.metadata.name + ); + } + } + } catch (error) { + console.log(` Error checking InstallPlans in ${namespaceName}: ${error.message}`); + } + + // Clean operand instances (infinispans, backups, etc.) + const operandTypes = [ + { group: 'infinispan.org', version: 'v1', plural: 'infinispans' }, + { group: 'infinispan.org', version: 'v1', plural: 'backups' }, + ]; + + for (const operandType of operandTypes) { + try { + const operands = await k8sClient.listCustomResources( + operandType.group, + operandType.version, + namespaceName, + operandType.plural + ); + + console.log(`Found ${(operands || []).length} ${operandType.plural} in ${namespaceName}`); + + for (const operand of operands || []) { + console.log(` ${dryRun ? 'Would delete' : 'Deleting'} ${operandType.plural}: ${operand.metadata.name}`); + if (!dryRun) { + await k8sClient.deleteCustomResource( + operandType.group, + operandType.version, + namespaceName, + operandType.plural, + operand.metadata.name + ); + } + } + } catch (error) { + // Ignore - operand type may not exist + } + } + + // Delete the entire test namespace if old enough + console.log(` ${dryRun ? 'Would delete' : 'Deleting'} namespace: ${namespaceName}`); + if (!dryRun) { + try { + await k8sClient.deleteNamespace(namespaceName); + console.log(` ✅ Deleted namespace ${namespaceName}`); + } catch (error) { + console.log(` ❌ Error deleting namespace ${namespaceName}: ${error.message}`); + } + } + } + + console.log(`\n=== CLEANUP COMPLETE ${dryRun ? '(DRY RUN)' : ''} ===`); + if (dryRun) { + console.log('Re-run with dryRun: false to actually delete resources'); + } + + } catch (error) { + console.error('Error during cluster cleanup:', error); + throw error; + } +} \ No newline at end of file diff --git a/frontend/e2e/test-utils/olm-cleanup.ts b/frontend/e2e/test-utils/olm-cleanup.ts new file mode 100644 index 00000000000..a800feba921 --- /dev/null +++ b/frontend/e2e/test-utils/olm-cleanup.ts @@ -0,0 +1,116 @@ +/** + * Comprehensive OLM cleanup utility for dealing with persistent operator resources + * that OLM sometimes fails to clean up automatically + */ + +export interface OLMCleanupOptions { + operatorPackageName: string; + namespacePattern?: string; // e.g., "test-" to match test namespaces + dryRun?: boolean; + forceDelete?: boolean; +} + +/** + * Clean up all OLM resources for an operator, including the cluster-scoped Operator resources + * that sometimes persist after namespace deletion + */ +export async function cleanupOLMOperatorCompletely( + k8sClient: any, + options: OLMCleanupOptions +): Promise { + const { operatorPackageName, namespacePattern, dryRun = false, forceDelete = false } = options; + + console.log(`\n=== COMPREHENSIVE OLM CLEANUP ${dryRun ? '(DRY RUN)' : ''} ===`); + console.log(`Operator: ${operatorPackageName}`); + console.log(`Namespace pattern: ${namespacePattern || 'all'}`); + + try { + // 1. Clean up cluster-scoped Operator resources first + console.log('\n--- Cleaning cluster-scoped Operator resources ---'); + + const operators = await k8sClient.listClusterCustomResources( + 'operators.coreos.com', + 'v1', + 'operators' + ); + + const matchingOperators = (operators || []).filter((op: any) => { + const opName = op.metadata.name; + // Match pattern: operatorPackageName.namespace + const matchesPackage = opName.startsWith(`${operatorPackageName}.`); + const matchesNamespacePattern = namespacePattern + ? opName.includes(namespacePattern) + : true; + + return matchesPackage && matchesNamespacePattern; + }); + + console.log(`Found ${matchingOperators.length} matching Operator resources`); + + for (const operator of matchingOperators) { + console.log(`${dryRun ? 'Would delete' : 'Deleting'} Operator: ${operator.metadata.name}`); + + if (!dryRun) { + try { + // Try normal deletion first + await k8sClient.deleteClusterCustomResource( + 'operators.coreos.com', + 'v1', + 'operators', + operator.metadata.name + ); + console.log(`✅ Deleted Operator ${operator.metadata.name}`); + } catch (error) { + if (forceDelete) { + console.log(`⚠️ Normal deletion failed, trying force delete for ${operator.metadata.name}`); + // Force delete if normal deletion fails (this requires shell access) + // k8sClient doesn't support force deletion, so we'd need to use kubectl/oc + } else { + console.log(`❌ Failed to delete Operator ${operator.metadata.name}: ${error.message}`); + } + } + } + } + + // 2. Find and clean namespaces matching the pattern + if (namespacePattern) { + console.log(`\n--- Cleaning namespaces matching pattern: ${namespacePattern} ---`); + + // Note: k8sClient doesn't expose namespace listing, so we'll document the limitation + console.log('⚠️ Namespace cleanup requires manual intervention due to k8sClient limitations'); + console.log(`Manual command: kubectl delete namespace $(kubectl get namespaces -o name | grep ${namespacePattern})`); + } + + console.log(`\n=== CLEANUP COMPLETE ${dryRun ? '(DRY RUN)' : ''} ===`); + + } catch (error) { + console.error('Error during OLM cleanup:', error.message); + throw error; + } +} + +/** + * Enhanced cleanup that combines namespace-scoped and cluster-scoped cleanup + */ +export async function cleanupOperatorWithOLMResources( + k8sClient: any, + operatorPackageName: string, + namespace: string, + operandPlural?: string, + testOperand?: any +): Promise { + // First run the regular cleanup + const { cleanupOperatorResources } = await import('./operator-cleanup'); + await cleanupOperatorResources(k8sClient, { + operatorPackageName, + operandPlural, + testOperand, + namespace, + }); + + // Then clean up the cluster-scoped OLM resources + await cleanupOLMOperatorCompletely(k8sClient, { + operatorPackageName, + namespacePattern: namespace.startsWith('test-') ? 'test-' : undefined, + }); +} \ No newline at end of file diff --git a/frontend/e2e/test-utils/olm-test-cleanup.ts b/frontend/e2e/test-utils/olm-test-cleanup.ts new file mode 100644 index 00000000000..19fa0b80609 --- /dev/null +++ b/frontend/e2e/test-utils/olm-test-cleanup.ts @@ -0,0 +1,177 @@ +import type { TestOperandProps } from '../pages/operator-details-page'; +import { cleanupOperatorResources, cleanupAllOperatorsByPackageName } from './operator-cleanup'; + +export interface OperatorTestConfig { + operatorName: string; + operatorCardTestID: string; + packageName: string; + operand?: TestOperandProps & { plural: string }; + globalNamespace?: string; +} + +/** + * Standard operator cleanup for test setup/teardown + */ +export async function performOperatorCleanup(k8sClient: any, config: OperatorTestConfig): Promise { + const { packageName, operand, globalNamespace = 'openshift-operators' } = config; + + console.log(`🧹 Starting standard cleanup for ${packageName} operator...`); + + try { + // 1. Aggressive cluster-wide cleanup + await cleanupAllOperatorsByPackageName(k8sClient, packageName); + + // 2. Clean up specific namespaces with operand context + const targetNamespaces = [globalNamespace, 'openshift-marketplace']; + for (const namespace of targetNamespaces) { + await cleanupOperatorResources(k8sClient, { + operatorPackageName: packageName, + operandPlural: operand?.plural, + testOperand: operand, + namespace, + }); + } + + // 3. Clean up test namespaces + await cleanupTestNamespaces(k8sClient, config); + + console.log(`✅ Standard cleanup complete for ${packageName}`); + } catch (error) { + console.log(`Error during ${packageName} cleanup:`, error.message); + } +} + +/** + * Aggressive cleanup that also removes lingering operators and test namespaces + */ +export async function performAggressiveOperatorCleanup(k8sClient: any, config: OperatorTestConfig): Promise { + const { packageName } = config; + + console.log(`🔥 Starting aggressive cleanup for ${packageName} operator...`); + + // Start with standard cleanup + await performOperatorCleanup(k8sClient, config); + + try { + // Additional aggressive steps + await cleanupTestNamespaces(k8sClient, config, true); // deleteNamespaces = true + + // Wait for cleanup to propagate + console.log('⏳ Waiting for cleanup to propagate...'); + await new Promise(resolve => setTimeout(resolve, 10_000)); + + // Verify and force-delete any remaining operators + await verifyAndForceCleanup(k8sClient, packageName); + + console.log(`✅ Aggressive cleanup complete for ${packageName}`); + } catch (error) { + console.log(`Error during aggressive ${packageName} cleanup:`, error.message); + } +} + +/** + * Clean up test namespaces with operator resources + */ +async function cleanupTestNamespaces(k8sClient: any, config: OperatorTestConfig, deleteNamespaces: boolean = false): Promise { + const { packageName, operand } = config; + + try { + const namespaces = await k8sClient.listNamespaces(); + const testNamespaces = namespaces.filter((ns: any) => { + const name: string | undefined = ns?.metadata?.name; + return Boolean(name && (name.startsWith('test-') || name.includes(packageName))); + }); + + for (const testNs of testNamespaces) { + const nsName = (testNs as any)?.metadata?.name; + if (!nsName) continue; + + console.log(`Cleaning up test namespace: ${nsName}`); + try { + await cleanupOperatorResources(k8sClient, { + operatorPackageName: packageName, + operandPlural: operand?.plural, + testOperand: operand, + namespace: nsName, + }); + + // Optionally delete the entire namespace + if (deleteNamespaces && nsName.startsWith('test-')) { + await k8sClient.deleteNamespace(nsName); + console.log(`✅ Deleted test namespace: ${nsName}`); + } + } catch (error) { + console.log(`Error cleaning up namespace ${nsName}:`, error.message); + } + } + } catch (error) { + console.log('Error cleaning up test namespaces:', error.message); + } +} + +/** + * Verify cleanup worked and force-delete any remaining operators + */ +async function verifyAndForceCleanup(k8sClient: any, packageName: string): Promise { + try { + const remainingOperators = await k8sClient.listClusterCustomResources('operators.coreos.com', 'v1', 'operators'); + const matchingOperators = remainingOperators.filter((op: any) => + op.metadata.name?.includes(packageName) + ); + + if (matchingOperators.length > 0) { + console.log(`⚠️ Warning: Found remaining ${packageName} operators after cleanup:`, + matchingOperators.map((op: any) => op.metadata.name) + ); + + // Try to force delete them + for (const op of matchingOperators) { + try { + console.log(`🗑️ Force deleting operator: ${op.metadata.name}`); + await k8sClient.deleteClusterCustomResource('operators.coreos.com', 'v1', 'operators', op.metadata.name); + } catch (error) { + console.log(`Failed to force delete ${op.metadata.name}:`, error.message); + } + } + } else { + console.log(`✅ No remaining ${packageName} operators found`); + } + } catch (error) { + console.log('Could not verify cleanup state:', error.message); + } +} + +/** + * Create standard test hooks for any operator test + */ +export function createOperatorTestHooks(config: OperatorTestConfig) { + return { + beforeEach: async ({ k8sClient, page }: any) => { + console.log(`=== ${config.packageName.toUpperCase()} BEFORE EACH: Starting cleanup ===`); + await performOperatorCleanup(k8sClient, config); + await page.waitForTimeout(5000); // Wait for cleanup to propagate + console.log(`=== ${config.packageName.toUpperCase()} BEFORE EACH: Cleanup complete ===`); + }, + + afterEach: async ({ k8sClient }: any) => { + console.log(`=== ${config.packageName.toUpperCase()} AFTER EACH: Starting safety cleanup ===`); + // Give UI operations time to complete + await new Promise(resolve => setTimeout(resolve, 5000)); + await performOperatorCleanup(k8sClient, config); + console.log(`=== ${config.packageName.toUpperCase()} AFTER EACH: Cleanup complete ===`); + }, + + beforeAll: async ({ k8sClient }: any) => { + console.log(`=== ${config.packageName.toUpperCase()} BEFORE ALL: Starting standard cleanup ===`); + // Use standard cleanup instead of aggressive to avoid affecting cluster-wide resources + await performOperatorCleanup(k8sClient, config); + console.log(`=== ${config.packageName.toUpperCase()} BEFORE ALL: Cleanup complete ===`); + }, + + afterAll: async ({ k8sClient }: any) => { + console.log(`=== ${config.packageName.toUpperCase()} AFTER ALL: Starting final cleanup ===`); + await performAggressiveOperatorCleanup(k8sClient, config); + console.log(`=== ${config.packageName.toUpperCase()} AFTER ALL: Cleanup complete ===`); + }, + }; +} \ No newline at end of file diff --git a/frontend/e2e/test-utils/operator-cleanup.ts b/frontend/e2e/test-utils/operator-cleanup.ts new file mode 100644 index 00000000000..2cb7ea60754 --- /dev/null +++ b/frontend/e2e/test-utils/operator-cleanup.ts @@ -0,0 +1,141 @@ +import type { TestOperandProps } from '../pages/operator-details-page'; + +export interface OperatorCleanupOptions { + operatorPackageName: string; + operandPlural?: string; + testOperand?: TestOperandProps; + namespace: string; +} + +/** + * Simple cleanup that deletes subscriptions and lets OLM handle the rest + */ +export async function cleanupAllOperatorsByPackageName(k8sClient: any, operatorPackageName: string): Promise { + console.log(`🧹 Cleaning up ${operatorPackageName} operators by removing subscriptions...`); + + try { + // Get all namespaces + const namespaces = await k8sClient.listNamespaces(); + + for (const namespace of namespaces) { + const nsName = namespace?.metadata?.name; + if (!nsName) continue; + + try { + // List subscriptions in this namespace + const subscriptions = await k8sClient.listCustomResources( + 'operators.coreos.com', + 'v1alpha1', + nsName, + 'subscriptions', + ); + + // Find matching subscriptions + const matchingSubscriptions = subscriptions.filter((sub: any) => { + return sub.metadata.name === operatorPackageName || sub.spec?.name === operatorPackageName; + }); + + // Delete matching subscriptions + for (const subscription of matchingSubscriptions) { + console.log(`Deleting subscription ${subscription.metadata.name} in namespace ${nsName}`); + await k8sClient.deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + nsName, + 'subscriptions', + subscription.metadata.name, + ); + } + } catch (error) { + // Ignore errors for individual namespaces + } + } + + // Give OLM time to clean up everything else + console.log('Waiting 5 seconds for OLM to clean up dependent resources...'); + await new Promise(resolve => setTimeout(resolve, 5000)); + + } catch (error) { + console.log('Error during subscription cleanup:', error.message); + } + + console.log('Simple cleanup complete'); +} + +/** + * Clean up operator resources in a specific namespace + */ +export async function cleanupOperatorResources(k8sClient: any, options: OperatorCleanupOptions): Promise { + const { operatorPackageName, operandPlural, testOperand, namespace } = options; + + console.log(`Cleaning up ${operatorPackageName} resources in ${namespace}...`); + + try { + // 1. Delete operand instances if provided + if (testOperand && operandPlural) { + try { + await k8sClient.deleteCustomResource( + testOperand.group, + testOperand.version, + namespace, + operandPlural, + testOperand.exampleName, + ); + console.log(`✅ Deleted operand ${testOperand.exampleName}`); + } catch (error) { + // Ignore if not found + } + } + + // 2. Delete subscription (the root cause) - let OLM handle the rest + const subscriptions = await k8sClient.listCustomResources( + 'operators.coreos.com', + 'v1alpha1', + namespace, + 'subscriptions', + ); + + const matchingSubscriptions = subscriptions.filter((sub: any) => { + return sub.metadata.name === operatorPackageName || sub.spec?.name === operatorPackageName; + }); + + for (const subscription of matchingSubscriptions) { + console.log(`Deleting subscription: ${subscription.metadata.name}`); + await k8sClient.deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + namespace, + 'subscriptions', + subscription.metadata.name, + ); + console.log(`✅ Deleted subscription ${subscription.metadata.name}`); + } + + // 3. Clean up OperatorGroups in test namespaces to prevent conflicts + if (namespace.startsWith('test-')) { + const operatorGroups = await k8sClient.listCustomResources( + 'operators.coreos.com', + 'v1', + namespace, + 'operatorgroups', + ); + + for (const og of operatorGroups) { + console.log(`Deleting OperatorGroup: ${og.metadata.name}`); + await k8sClient.deleteCustomResource( + 'operators.coreos.com', + 'v1', + namespace, + 'operatorgroups', + og.metadata.name, + ); + console.log(`✅ Deleted OperatorGroup ${og.metadata.name}`); + } + } + + } catch (error) { + console.log(`Error cleaning up ${operatorPackageName} in ${namespace}:`, error.message); + } + + console.log(`✅ Cleanup complete for ${namespace}`); +} \ No newline at end of file diff --git a/frontend/e2e/test-utils/test-namespace.ts b/frontend/e2e/test-utils/test-namespace.ts new file mode 100644 index 00000000000..d09fbe63438 --- /dev/null +++ b/frontend/e2e/test-utils/test-namespace.ts @@ -0,0 +1,12 @@ +/** + * Generate a randomized test namespace name + * Format: test-{5 random lowercase letters} + */ +export function generateTestNamespace(): string { + const chars = 'abcdefghijklmnopqrstuvwxyz'; + let suffix = ''; + for (let i = 0; i < 5; i++) { + suffix += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return `test-${suffix}`; +} \ No newline at end of file diff --git a/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts b/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts index eca93adc6bb..5a01f113148 100644 --- a/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts +++ b/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts @@ -19,6 +19,7 @@ test.describe('Add storage for workloads', { tag: ['@admin'] }, () => { test.beforeAll(async ({ k8sClient }) => { namespace = `test-storage-${Date.now()}`; await k8sClient.createNamespace(namespace); + await k8sClient.waitForNamespaceReady(namespace); }); test.afterAll(async ({ k8sClient }) => { diff --git a/frontend/e2e/tests/console/crud/annotations.spec.ts b/frontend/e2e/tests/console/crud/annotations.spec.ts index 828ee8002fb..9e5fac7cc6a 100644 --- a/frontend/e2e/tests/console/crud/annotations.spec.ts +++ b/frontend/e2e/tests/console/crud/annotations.spec.ts @@ -151,7 +151,7 @@ test.describe('Annotations', { tag: ['@admin'] }, () => { await test.step('Delete all annotations', async () => { await page.getByTestId('delete-button').first().click(); - await page.getByTestId('delete-button').click(); + await page.getByTestId('delete-button').first().click(); await modal.submit(); await modal.waitForClosed(); await expect(page.getByTestId('edit-annotations')).toContainText('0 annotations'); diff --git a/frontend/e2e/tests/console/crud/customresourcedefinition.spec.ts b/frontend/e2e/tests/console/crud/customresourcedefinition.spec.ts index 57206b6d1b8..bce62ad5324 100644 --- a/frontend/e2e/tests/console/crud/customresourcedefinition.spec.ts +++ b/frontend/e2e/tests/console/crud/customresourcedefinition.spec.ts @@ -97,7 +97,6 @@ test.describe('CustomResourceDefinitions', { tag: ['@admin'] }, () => { }; const customResource = { - name: crdName, apiVersion: `${group}/v1`, kind: crdKind, metadata: { @@ -105,7 +104,6 @@ test.describe('CustomResourceDefinitions', { tag: ['@admin'] }, () => { namespace, }, spec: {}, - plural: 'customresourcedefinitions', }; await test.step('Create CRD via YAML editor', async () => { diff --git a/frontend/e2e/tests/console/crud/other-routes.spec.ts b/frontend/e2e/tests/console/crud/other-routes.spec.ts index 87cd3ca0684..bcfd82d3f01 100644 --- a/frontend/e2e/tests/console/crud/other-routes.spec.ts +++ b/frontend/e2e/tests/console/crud/other-routes.spec.ts @@ -138,7 +138,8 @@ test.describe('Visiting other routes', { tag: ['@admin', '@smoke'] }, () => { page, }) => { await page.goto(route.path, { timeout: 90_000 }); - await expect(page).toHaveURL(new RegExp(route.path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + const expectedPath = route.path.split('?')[0]; + await expect(page).toHaveURL(new RegExp(expectedPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); await expect(page.getByTestId('loading-indicator')).toHaveCount(0); await expect(page.getByTestId('error-page')).not.toBeAttached(); diff --git a/frontend/e2e/tests/console/crud/quotas.spec.ts b/frontend/e2e/tests/console/crud/quotas.spec.ts index 63cbc27ac41..c0b53ce4515 100644 --- a/frontend/e2e/tests/console/crud/quotas.spec.ts +++ b/frontend/e2e/tests/console/crud/quotas.spec.ts @@ -23,12 +23,16 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { }); test.afterAll(async ({ k8sClient }) => { - await k8sClient.deleteClusterCustomResource( - 'quota.openshift.io', - 'v1', - 'clusterresourcequotas', - clusterQuotaName, - ); + try { + await k8sClient.deleteClusterCustomResource( + 'quota.openshift.io', + 'v1', + 'clusterresourcequotas', + clusterQuotaName, + ); + } catch (error) { + console.warn(`[Cleanup] ClusterResourceQuota ${clusterQuotaName}: ${String(error)}`); + } await k8sClient.deleteNamespace(namespace); }); diff --git a/frontend/e2e/tests/olm/catalog-source-details.spec.ts b/frontend/e2e/tests/olm/catalog-source-details.spec.ts new file mode 100644 index 00000000000..6ad3cf5513d --- /dev/null +++ b/frontend/e2e/tests/olm/catalog-source-details.spec.ts @@ -0,0 +1,135 @@ +import { test, expect } from '../../fixtures'; +import { CatalogSourcePage } from '../../pages/catalog-source-page'; + +const managedCatalogSource = { + name: 'redhat-operators', + displayName: 'Red Hat Operators', +}; + +test.describe('CatalogSource details page', { tag: ['@admin'] }, () => { + test('renders details about a managed catalog source', async ({ page }) => { + const catalogSourcePage = new CatalogSourcePage(page); + + await test.step('Navigate to CatalogSource details', async () => { + await catalogSourcePage.navigateToOperatorHubSources(); + await catalogSourcePage.openCatalogSourceDetails(managedCatalogSource.name); + }); + + await test.step('Verify section heading', async () => { + await expect(catalogSourcePage.getSectionHeading('CatalogSource details')).toBeVisible(); + }); + + await test.step('Verify Status is READY', async () => { + await expect(catalogSourcePage.getDetailsValue('Status')).toHaveText('READY', { + timeout: 300_000, + }); + }); + + await test.step('Verify Name field', async () => { + await expect(catalogSourcePage.getDetailsLabel('Name')).toBeVisible(); + await expect(catalogSourcePage.getDetailsValue('Name')).toHaveText( + managedCatalogSource.name, + ); + }); + + await test.step('Verify Status label is visible', async () => { + await expect(catalogSourcePage.getDetailsLabel('Status')).toBeVisible(); + }); + + await test.step('Verify Display name field', async () => { + await expect(catalogSourcePage.getDetailsLabel('Display name')).toBeVisible(); + await expect(catalogSourcePage.getDetailsValue('Display name')).toHaveText( + managedCatalogSource.displayName, + ); + }); + + await test.step('Verify Registry poll interval field', async () => { + await expect(catalogSourcePage.getDetailsValue('Registry poll interval')).toBeVisible(); + }); + + await test.step('Verify Number of Operators field', async () => { + await expect(catalogSourcePage.getDetailsLabel('Number of Operators')).toBeVisible(); + await expect(catalogSourcePage.getDetailsValue('Number of Operators')).toBeVisible(); + }); + }); + + test('lists package manifests under Operators tab', async ({ page }) => { + const catalogSourcePage = new CatalogSourcePage(page); + + await test.step('Navigate to CatalogSource details', async () => { + await catalogSourcePage.navigateToOperatorHubSources(); + await catalogSourcePage.openCatalogSourceDetails(managedCatalogSource.name); + await expect(catalogSourcePage.getSectionHeading('CatalogSource details')).toBeVisible(); + }); + + await test.step('Verify PackageManifest table on Operators tab', async () => { + await catalogSourcePage.selectOperatorsTab(); + await expect(catalogSourcePage.getPackageManifestTable()).toBeAttached(); + }); + }); + + test('allows modifying registry poll interval', async ({ page, k8sClient, cleanup }) => { + const testNs = `test-catsrc-${Date.now()}`; + const catalogSourceName = `test-catsrc-${Date.now()}`; + const catalogSourcePage = new CatalogSourcePage(page); + + await test.step('Create test namespace and CatalogSource', async () => { + await k8sClient.createNamespace(testNs); + cleanup.trackNamespace(testNs); + + await k8sClient.createCustomResource( + 'operators.coreos.com', + 'v1alpha1', + testNs, + 'catalogsources', + { + apiVersion: 'operators.coreos.com/v1alpha1', + kind: 'CatalogSource', + metadata: { + name: catalogSourceName, + namespace: testNs, + }, + spec: { + displayName: 'Test catalog', + image: '', + sourceType: 'grpc', + updateStrategy: { + registryPoll: { + interval: '10m', + }, + }, + }, + }, + ); + cleanup.trackCustomResource( + catalogSourceName, + testNs, + 'operators.coreos.com', + 'v1alpha1', + 'catalogsources', + ); + }); + + await test.step('Navigate to test CatalogSource details', async () => { + await catalogSourcePage.navigateToOperatorHubSources(); + + // Wait for the CatalogSource to appear in the UI after K8s creation + await expect(page.getByTestId(catalogSourceName)).toBeVisible({ timeout: 60_000 }); + + await catalogSourcePage.openCatalogSourceDetails(catalogSourceName); + }); + + await test.step('Edit registry poll interval to 30m', async () => { + await catalogSourcePage.clickEditRegistryPollInterval(); + await expect(catalogSourcePage.getRegistryPollIntervalModalTitle()).toContainText( + 'Edit registry poll interval', + ); + await catalogSourcePage.selectPollInterval('30m'); + await catalogSourcePage.submitPollIntervalModal(); + }); + + await test.step('Verify registry poll interval updated', async () => { + await expect(catalogSourcePage.getDetailsValue('Registry poll interval')).toHaveText('30m'); + }); + }); +}); diff --git a/frontend/e2e/tests/olm/create-namespace.spec.ts b/frontend/e2e/tests/olm/create-namespace.spec.ts index 45263faca56..a5c8923f392 100644 --- a/frontend/e2e/tests/olm/create-namespace.spec.ts +++ b/frontend/e2e/tests/olm/create-namespace.spec.ts @@ -1,55 +1,75 @@ import { test, expect } from '../../fixtures'; import KubernetesClient from '../../clients/kubernetes-client'; +import { cleanupOperatorResources, cleanupAllOperatorsByPackageName } from '../../test-utils/operator-cleanup'; +import { generateTestNamespace } from '../../test-utils/test-namespace'; const operatorName = '3scale API Management'; +const operatorPackageName = '3scale-community-operator'; test.describe('Create namespace from install operators', { tag: ['@admin'] }, () => { let k8sClient: KubernetesClient; let nsName: string; - test.beforeEach(async ({ k8sClient: client }) => { + test.beforeEach(async ({ k8sClient: client, page }) => { + console.log('=== CREATE NAMESPACE BEFORE EACH: Starting cleanup ==='); + k8sClient = client; - nsName = `test-create-ns-${Date.now()}`; - }); + nsName = generateTestNamespace(); - test.afterEach(async () => { - try { - await k8sClient.deleteCustomResource( - 'operators.coreos.com', - 'v1alpha1', - nsName, - 'subscriptions', - '3scale-community-operator', - ); - } catch { - // Ignore if not created - } + // Do aggressive cluster-wide cleanup for 3scale operators + await cleanupAllOperatorsByPackageName(k8sClient, operatorPackageName); + + // Clean up any test namespaces from previous runs try { - const csvs = (await k8sClient.listCustomResources( - 'operators.coreos.com', - 'v1alpha1', - nsName, - 'clusterserviceversions', - )) as Array<{ metadata?: { name?: string } }>; - for (const csv of csvs) { - if (csv.metadata?.name) { - await k8sClient.deleteCustomResource( - 'operators.coreos.com', - 'v1alpha1', - nsName, - 'clusterserviceversions', - csv.metadata.name, - ); + const namespaces = await k8sClient.listNamespaces(); + const testNamespaces = namespaces.filter((ns: any) => ns.metadata.name.startsWith('test-create-ns-')); + + for (const testNs of testNamespaces) { + const nsName = (testNs as any)?.metadata?.name; + if (nsName) { + console.log(`Cleaning up test namespace: ${nsName}`); + await cleanupOperatorResources(k8sClient, { + operatorPackageName, + namespace: nsName, + }); + // Also delete the namespace itself + try { + await k8sClient.deleteNamespace(nsName); + } catch (error) { + console.log(`Failed to delete namespace ${nsName}:`, error.message); + } } } - } catch { - // Ignore cleanup errors + } catch (error) { + console.log('Error cleaning up test namespaces:', error.message); } + + // Wait for cleanup to propagate + await page.waitForTimeout(10000); + console.log('=== CREATE NAMESPACE BEFORE EACH: Cleanup complete ==='); + }); + + test.afterEach(async () => { + console.log('=== CREATE NAMESPACE AFTER EACH: Starting cleanup ==='); + + // Use our comprehensive cleanup for the test namespace + await cleanupOperatorResources(k8sClient, { + operatorPackageName, + namespace: nsName, + }); + + // Also do cluster-wide cleanup + await cleanupAllOperatorsByPackageName(k8sClient, operatorPackageName); + + // Delete the test namespace try { await k8sClient.deleteNamespace(nsName); - } catch { - // Ignore if not created + console.log(`✅ Deleted test namespace: ${nsName}`); + } catch (error) { + console.log(`❌ Failed to delete namespace ${nsName}:`, error.message); } + + console.log('=== CREATE NAMESPACE AFTER EACH: Cleanup complete ==='); }); test('creates namespace from operator install page', async ({ page }) => { diff --git a/frontend/e2e/tests/olm/descriptors.spec.ts b/frontend/e2e/tests/olm/descriptors.spec.ts new file mode 100644 index 00000000000..ad88b462a1f --- /dev/null +++ b/frontend/e2e/tests/olm/descriptors.spec.ts @@ -0,0 +1,470 @@ +import { test, expect } from '../../fixtures'; +import { OperandPage } from '../../pages/operand-page'; + +const CRD_NAME = 'apps.test.tectonic.com'; +const CRD_GROUP = 'test.tectonic.com'; +const CRD_VERSION = 'v1'; +const CRD_KIND = 'App'; +const CRD_PLURAL = 'apps'; +const CSV_NAME = 'olm-descriptors-test'; +const CR_NAME = 'olm-descriptors-test'; + +const FIELD_IDS = { + NAME: 'root_metadata_name', + PASSWORD: 'root_spec_password', + NUMBER: 'root_spec_number', + SELECT: 'root_spec_select', + LABELS: 'root_metadata_labels', + FIELD_GROUP: 'root_spec_fieldGroup', + ARRAY_FIELD_GROUP: 'root_spec_arrayFieldGroup', +}; + +const visibleSpecDescriptors = [ + 'Pod Count', + 'Endpoint List', + 'Label', + 'Resource Requirements', + 'Namespace Selector', + 'Boolean Switch', + 'Password', + 'Checkbox', + 'Image Pull Policy', + 'Update Strategy', + 'Text', + 'Number', + 'Node Affinity', + 'Pod Affinity', + 'Pod Anti Affinity', + 'Advanced', + 'Field Dependency', +]; +const hiddenSpecDescriptors = ['Hidden']; + +const visibleStatusDescriptors = [ + 'Pod Statuses', + 'Pod Count', + 'W3 Link', + 'Text', + 'Prometheus Endpoint', + 'K8s Phase', + 'K8s Phase Reason', + 'Password', +]; +const hiddenStatusDescriptors = ['Hidden']; + +function buildTestCRD() { + return { + apiVersion: 'apiextensions.k8s.io/v1', + kind: 'CustomResourceDefinition', + metadata: { name: CRD_NAME }, + spec: { + group: CRD_GROUP, + scope: 'Namespaced', + names: { plural: CRD_PLURAL, singular: 'app', kind: CRD_KIND, listKind: 'Apps' }, + versions: [ + { + name: CRD_VERSION, + subresources: { status: {} }, + served: true, + storage: true, + schema: { + openAPIV3Schema: { + type: 'object', + properties: { + spec: { + type: 'object', + required: ['password', 'select'], + properties: { + password: { + type: 'string', + minLength: 1, + maxLength: 25, + pattern: '^[a-zA-Z0-9._\\-%]*$', + }, + number: { type: 'integer', minimum: 2, maximum: 4 }, + select: { + type: 'string', + title: 'Select', + enum: ['DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL'], + }, + fieldGroup: { + type: 'object', + properties: { + itemOne: { type: 'string' }, + itemTwo: { type: 'integer' }, + }, + }, + arrayFieldGroup: { + type: 'array', + items: { + type: 'object', + properties: { + itemOne: { title: 'Item One', type: 'string' }, + itemTwo: { title: 'Item Two', type: 'integer' }, + }, + }, + }, + hiddenFieldGroup: { + type: 'object', + properties: { hiddenItem: { type: 'object' } }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + }; +} + +function buildTestCR(ns: string) { + return { + apiVersion: `${CRD_GROUP}/${CRD_VERSION}`, + kind: CRD_KIND, + metadata: { + name: CR_NAME, + namespace: ns, + labels: { automatedTestName: ns }, + }, + spec: { + fieldGroup: { itemOne: 'Field group item 1', itemTwo: 2 }, + arrayFieldGroup: [{ itemOne: 'Array field group item 1', itemTwo: 2 }], + select: 'WARN', + podCount: 3, + endpointList: [{ port: 8080, scheme: 'TCP' }], + label: 'app=openshift', + resourceRequirements: { + limits: { cpu: '500m', memory: '50Mi', 'ephemeral-storage': '500Gi' }, + requests: { cpu: '500m', memory: '50Mi', 'ephemeral-storage': '500Gi' }, + }, + namespaceSelector: { matchNames: ['default'] }, + booleanSwitch: true, + password: 'password123', + checkbox: true, + imagePullPolicy: 'Never', + updateStrategy: { type: 'Recreate' }, + text: 'Some text', + number: 2, + }, + status: { + podStatuses: { ready: ['pod-0', 'pod-1'], unhealthy: ['pod-2'], stopped: ['pod-3'] }, + podCount: 3, + w3Link: 'https://google.com', + conditions: [ + { + type: 'Available', + status: 'True', + lastUpdateTime: '2018-08-22T23:27:55Z', + lastTransitionTime: '2018-08-22T23:27:55Z', + reason: 'AppReady', + message: 'App is ready.', + }, + ], + text: 'Some text', + prometheusEndpoint: 'my-svc.my-namespace.svc.cluster.local', + k8sPhase: 'Available', + k8sPhaseReason: 'AppReady', + }, + }; +} + +const allSpecDescriptorPaths = [ + { path: 'podCount', displayName: 'Pod Count' }, + { path: 'endpointList', displayName: 'Endpoint List' }, + { path: 'label', displayName: 'Label' }, + { path: 'resourceRequirements', displayName: 'Resource Requirements' }, + { path: 'namespaceSelector', displayName: 'Namespace Selector' }, + { path: 'booleanSwitch', displayName: 'Boolean Switch' }, + { path: 'password', displayName: 'Password' }, + { path: 'checkbox', displayName: 'Checkbox' }, + { path: 'imagePullPolicy', displayName: 'Image Pull Policy' }, + { path: 'updateStrategy', displayName: 'Update Strategy' }, + { path: 'text', displayName: 'Text' }, + { path: 'number', displayName: 'Number' }, + { path: 'nodeAffinity', displayName: 'Node Affinity' }, + { path: 'podAffinity', displayName: 'Pod Affinity' }, + { path: 'podAntiAffinity', displayName: 'Pod Anti Affinity' }, + { path: 'advanced', displayName: 'Advanced' }, + { path: 'fieldDependency', displayName: 'Field Dependency' }, + { path: 'hidden', displayName: 'Hidden' }, +]; + +function buildSpecDescriptors() { + return allSpecDescriptorPaths.map((d) => ({ + description: `Spec descriptor for ${d.path}`, + displayName: d.displayName, + path: d.path, + 'x-descriptors': [`urn:alm:descriptor:com.tectonic.ui:${d.path}`], + })); +} + +const allStatusDescriptorPaths = [ + { path: 'podStatuses', displayName: 'Pod Statuses' }, + { path: 'podCount', displayName: 'Pod Count' }, + { path: 'w3Link', displayName: 'W3 Link' }, + { path: 'conditions', displayName: 'Conditions' }, + { path: 'text', displayName: 'Text' }, + { path: 'prometheusEndpoint', displayName: 'Prometheus Endpoint' }, + { path: 'k8sPhase', displayName: 'K8s Phase' }, + { path: 'k8sPhaseReason', displayName: 'K8s Phase Reason' }, + { path: 'password', displayName: 'Password' }, + { path: 'hidden', displayName: 'Hidden' }, +]; + +const statusCapabilityUrns: Record = { + podStatuses: 'urn:alm:descriptor:com.tectonic.ui:podStatuses', + podCount: 'urn:alm:descriptor:com.tectonic.ui:podCount', + w3Link: 'urn:alm:descriptor:org.w3:link', + conditions: 'urn:alm:descriptor:io.kubernetes.conditions', + text: 'urn:alm:descriptor:text', + prometheusEndpoint: 'urn:alm:descriptor:prometheusEndpoint', + k8sPhase: 'urn:alm:descriptor:io.kubernetes.phase', + k8sPhaseReason: 'urn:alm:descriptor:io.kubernetes.phase:reason', + password: 'urn:alm:descriptor:com.tectonic.ui:password', + hidden: 'urn:alm:descriptor:com.tectonic.ui:hidden', +}; + +function buildStatusDescriptors() { + return allStatusDescriptorPaths.map((d) => ({ + description: `Status descriptor for ${d.path}`, + displayName: d.displayName, + path: d.path, + 'x-descriptors': [statusCapabilityUrns[d.path]], + })); +} + +function buildTestCSV(ns: string, cr: ReturnType) { + return { + apiVersion: 'operators.coreos.com/v1alpha1', + kind: 'ClusterServiceVersion', + metadata: { + name: CSV_NAME, + namespace: ns, + annotations: { 'alm-examples': JSON.stringify([cr]) }, + }, + spec: { + displayName: 'Test Operator', + install: { + strategy: 'deployment', + spec: { + permissions: [], + deployments: [ + { + name: 'test-operator', + spec: { + replicas: 1, + selector: { matchLabels: { name: 'test-operator-alm-owned' } }, + template: { + metadata: { + name: 'test-operator-alm-owned', + labels: { name: 'test-operator-alm-owned' }, + }, + spec: { + serviceAccountName: 'test-operator', + containers: [{ name: 'test-operator', image: 'nginx' }], + }, + }, + }, + }, + ], + }, + }, + customresourcedefinitions: { + owned: [ + { + name: CRD_NAME, + version: CRD_VERSION, + kind: CRD_KIND, + displayName: CRD_KIND, + description: 'Application instance for testing descriptors', + resources: [], + specDescriptors: buildSpecDescriptors(), + statusDescriptors: buildStatusDescriptors(), + }, + ], + }, + }, + }; +} + +test.describe('Using OLM descriptor components', { tag: ['@admin'] }, () => { + let ns: string; + let csvUrl: string; + + test.beforeAll(async ({ k8sClient }) => { + ns = `test-desc-${Date.now()}`; + await k8sClient.createNamespace(ns); + + const testCRD = buildTestCRD(); + await k8sClient.createClusterCustomResource( + 'apiextensions.k8s.io', + 'v1', + 'customresourcedefinitions', + testCRD, + ); + + const testCR = buildTestCR(ns); + const testCSV = buildTestCSV(ns, testCR); + await k8sClient.createCustomResource( + 'operators.coreos.com', + 'v1alpha1', + ns, + 'clusterserviceversions', + testCSV, + ); + + csvUrl = `/k8s/ns/${ns}/operators.coreos.com~v1alpha1~ClusterServiceVersion/${CSV_NAME}/${CRD_GROUP}~${CRD_VERSION}~${CRD_KIND}`; + }); + + test.afterAll(async ({ k8sClient }) => { + await k8sClient.deleteClusterCustomResource( + 'apiextensions.k8s.io', + 'v1', + 'customresourcedefinitions', + CRD_NAME, + ); + await k8sClient.deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + ns, + 'clusterserviceversions', + CSV_NAME, + ); + await k8sClient.deleteNamespace(ns); + }); + + test('displays list and detail views of an operand', async ({ page, k8sClient, cleanup }) => { + const operandPage = new OperandPage(page); + const testCR = buildTestCR(ns); + + await test.step('Create test CR', async () => { + await k8sClient.createCustomResource(CRD_GROUP, CRD_VERSION, ns, CRD_PLURAL, testCR); + cleanup.trackCustomResource(CR_NAME, ns, CRD_GROUP, CRD_VERSION, CRD_PLURAL); + }); + + await test.step('Verify operand link on list page', async () => { + await operandPage.navigateTo(csvUrl); + await expect(operandPage.getOperandLink(CR_NAME)).toBeAttached(); + }); + + await test.step('Verify resource title on detail page', async () => { + await operandPage.navigateTo(`${csvUrl}/${CR_NAME}`); + await expect(operandPage.getResourceTitle()).toHaveText(CR_NAME); + }); + + await test.step('Verify visible spec descriptors', async () => { + for (const displayName of visibleSpecDescriptors) { + await expect(operandPage.getDetailsItemLabel(displayName)).toBeAttached(); + } + }); + + await test.step('Verify hidden spec descriptors are not rendered', async () => { + for (const displayName of hiddenSpecDescriptors) { + await expect(operandPage.getDetailsItemLabel(displayName)).not.toBeAttached(); + } + }); + + await test.step('Verify visible status descriptors', async () => { + for (const displayName of visibleStatusDescriptors) { + await expect(operandPage.getDetailsItemLabel(displayName)).toBeAttached(); + } + }); + + await test.step('Verify hidden status descriptors are not rendered', async () => { + for (const displayName of hiddenStatusDescriptors) { + await expect(operandPage.getDetailsItemLabel(displayName)).not.toBeAttached(); + } + }); + }); + + test('creates an operand using the form', async ({ page, cleanup }) => { + const operandPage = new OperandPage(page); + const testCR = buildTestCR(ns); + + await test.step('Navigate to create form', async () => { + await operandPage.navigateTo(csvUrl); + await operandPage.clickCreate(); + await expect(operandPage.getFormHeading()).toHaveText('Create App'); + }); + + await test.step('Verify atomic form fields', async () => { + const atomicFields = [ + { label: 'Name', id: FIELD_IDS.NAME, value: testCR.metadata.name }, + { label: 'Password', id: FIELD_IDS.PASSWORD, value: testCR.spec.password }, + { label: 'Number', id: FIELD_IDS.NUMBER, value: String(testCR.spec.number) }, + ]; + + for (const field of atomicFields) { + await expect(operandPage.getFormFieldElement(field.id)).toBeAttached(); + await expect(operandPage.getFormFieldLabel(field.id)).toHaveText(field.label); + await expect(operandPage.getFormFieldInput(field.id)).toHaveValue(field.value); + } + }); + + await test.step('Verify select field', async () => { + await expect(operandPage.getFormFieldElement(FIELD_IDS.SELECT)).toBeAttached(); + await expect(operandPage.getFormFieldLabel(FIELD_IDS.SELECT)).toHaveText('Select'); + await expect(operandPage.getFormFieldInput(FIELD_IDS.SELECT)).toHaveText( + testCR.spec.select, + ); + }); + + await test.step('Verify labels field', async () => { + await expect(operandPage.getFormFieldElement(FIELD_IDS.LABELS)).toBeAttached(); + await expect(operandPage.getFormFieldLabel(FIELD_IDS.LABELS)).toHaveText('Labels'); + await expect(operandPage.getTagItemContent(FIELD_IDS.LABELS)).toHaveText( + `automatedTestName=${ns}`, + ); + }); + + await test.step('Verify field group', async () => { + await expect(operandPage.getFormFieldGroup(FIELD_IDS.FIELD_GROUP)).toBeAttached(); + await operandPage.toggleFieldGroup(FIELD_IDS.FIELD_GROUP); + await expect( + operandPage.getFormFieldLabel(`${FIELD_IDS.FIELD_GROUP}_itemOne`), + ).toHaveText('itemOne'); + await expect( + operandPage.getFormFieldInput(`${FIELD_IDS.FIELD_GROUP}_itemOne`), + ).toHaveValue(testCR.spec.fieldGroup.itemOne); + await expect( + operandPage.getFormFieldLabel(`${FIELD_IDS.FIELD_GROUP}_itemTwo`), + ).toHaveText('itemTwo'); + await expect( + operandPage.getFormFieldInput(`${FIELD_IDS.FIELD_GROUP}_itemTwo`), + ).toHaveValue(String(testCR.spec.fieldGroup.itemTwo)); + }); + + await test.step('Verify array field group', async () => { + await expect(operandPage.getFormFieldGroup(FIELD_IDS.ARRAY_FIELD_GROUP)).toBeAttached(); + await operandPage.toggleFieldGroup(FIELD_IDS.ARRAY_FIELD_GROUP); + await expect( + operandPage.getFormFieldLabel(`${FIELD_IDS.ARRAY_FIELD_GROUP}_0_itemOne`), + ).toHaveText('Item One'); + await expect( + operandPage.getFormFieldInput(`${FIELD_IDS.ARRAY_FIELD_GROUP}_0_itemOne`), + ).toHaveValue(testCR.spec.arrayFieldGroup[0].itemOne); + await expect( + operandPage.getFormFieldLabel(`${FIELD_IDS.ARRAY_FIELD_GROUP}_0_itemTwo`), + ).toHaveText('Item Two'); + await expect( + operandPage.getFormFieldInput(`${FIELD_IDS.ARRAY_FIELD_GROUP}_0_itemTwo`), + ).toHaveValue(String(testCR.spec.arrayFieldGroup[0].itemTwo)); + }); + + await test.step('Verify hidden field group is not rendered', async () => { + await expect( + page.locator('#root_spec_hiddenFieldGroup_field-group'), + ).not.toBeAttached(); + }); + + await test.step('Submit form and verify operand created', async () => { + await operandPage.fillNameField(FIELD_IDS.NAME, CR_NAME); + await operandPage.submitCreateForm(); + cleanup.trackCustomResource(CR_NAME, ns, CRD_GROUP, CRD_VERSION, CRD_PLURAL); + await operandPage.clickOperandLink(CR_NAME); + await expect(operandPage.getOperandDetailsSection()).toBeAttached(); + }); + }); +}); diff --git a/frontend/e2e/tests/olm/edit-default-sources.spec.ts b/frontend/e2e/tests/olm/edit-default-sources.spec.ts new file mode 100644 index 00000000000..afe75a05f40 --- /dev/null +++ b/frontend/e2e/tests/olm/edit-default-sources.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '../../fixtures'; +import { OperatorHubDetailsPage } from '../../pages/operator-hub-details-page'; + +test.describe('OperatorHub default sources management', { tag: ['@admin'] }, () => { + test('disables and re-enables default catalog sources from OperatorHub details page', async ({ + page, + }) => { + const operatorHubPage = new OperatorHubDetailsPage(page); + const defaultSourceToBeToggled = 'redhat-operators'; + + await test.step('Navigate to OperatorHub page', async () => { + await operatorHubPage.navigateToOperatorHub(); + }); + + await test.step('Verify OperatorHub details page is open', async () => { + await operatorHubPage.verifySectionHeading('OperatorHub details'); + }); + + await test.step('Toggle default source and verify status changes', async () => { + // First toggle - disable the source + await operatorHubPage.openEditDefaultSourcesModal(); + await expect(operatorHubPage.getModal().getModalTitle()).toContainText('Edit default sources'); + await operatorHubPage.toggleDefaultSource(defaultSourceToBeToggled); + await operatorHubPage.submitModal(); + + // Verify status change to Disabled + await expect(operatorHubPage.getSourceStatus(defaultSourceToBeToggled)).toHaveText('Disabled'); + + // Second toggle - re-enable the source + await operatorHubPage.openEditDefaultSourcesModal(); + await expect(operatorHubPage.getModal().getModalTitle()).toContainText('Edit default sources'); + await operatorHubPage.toggleDefaultSource(defaultSourceToBeToggled); + await operatorHubPage.submitModal(); + + // Verify status change back to Enabled + await expect(operatorHubPage.getSourceStatus(defaultSourceToBeToggled)).toHaveText('Enabled'); + }); + }); +}); \ No newline at end of file diff --git a/frontend/e2e/tests/olm/operator-hub.spec.ts b/frontend/e2e/tests/olm/operator-hub.spec.ts new file mode 100644 index 00000000000..b2bb80d0102 --- /dev/null +++ b/frontend/e2e/tests/olm/operator-hub.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from '../../fixtures'; +import { CatalogPage } from '../../pages/catalog-page'; + +test.describe('Software Catalog Operator filtering', { tag: ['@admin'] }, () => { + test('displays Operator catalog items with expected available Operators', async ({ + page, + k8sClient, + cleanup, + }) => { + const catalogPage = new CatalogPage(page); + const testNamespace = `test-operators-${Date.now()}`; + + await test.step('Create test namespace', async () => { + await k8sClient.createNamespace(testNamespace); + cleanup.trackNamespace(testNamespace); + }); + + await test.step('Navigate to Software Catalog and verify page', async () => { + await catalogPage.navigateToSoftwareCatalog(testNamespace); + await expect(catalogPage.getPageHeading()).toContainText('Software Catalog'); + }); + + await test.step('Switch to Operators tab and verify tiles are present', async () => { + await catalogPage.clickOperatorTab(); + await expect(async () => { + const count = await catalogPage.getCatalogTiles().count(); + expect(count).toBeGreaterThan(0); + }).toPass(); + }); + + await test.step('Test Community filter functionality', async () => { + // Enable Community filter + await catalogPage.toggleSourceFilter('community'); + await expect(async () => { + const count = await catalogPage.getCatalogTiles().count(); + expect(count).toBeGreaterThan(0); + }).toPass(); + + // Track which tile is first with Community filter + const originalTileText = await catalogPage.getFirstCatalogTileTitle().textContent(); + + // Disable Community filter + await catalogPage.toggleSourceFilter('community'); + + // Enable Certified filter + await catalogPage.toggleSourceFilter('certified'); + await expect(async () => { + const count = await catalogPage.getCatalogTiles().count(); + expect(count).toBeGreaterThan(0); + }).toPass(); + + // Verify the first tile title is different from Community filter + await catalogPage.verifyTileTextChanged(originalTileText || ''); + }); + + await test.step('Test operator name search functionality', async () => { + const operatorName = 'Datadog Operator'; + + // Clear the Certified source filter left by previous test + await catalogPage.toggleSourceFilter('certified'); + + await catalogPage.searchOperators(operatorName); + await expect(async () => { + const count = await catalogPage.getCatalogTiles().count(); + expect(count).toBeGreaterThan(0); + }).toPass(); + await catalogPage.verifyTileContainsText(operatorName); + + // Clear the search + await catalogPage.clearSearchFilter(); + }); + + await test.step('Test empty search results and clear filters', async () => { + // Enter search query that returns zero results + await catalogPage.searchOperators('NoOperatorsTestXYZ123NonExistent'); + + // Wait for search to complete and verify no tiles + await expect(catalogPage.getCatalogTiles()).not.toBeAttached({ timeout: 10_000 }); + + // Check if clear filters button appears + const clearButton = catalogPage.getClearFiltersButton(); + if (await clearButton.count() > 0) { + await expect(clearButton).toBeVisible(); + await catalogPage.clickClearAllFilters(); + await expect(catalogPage.getSearchInput()).toBeEmpty(); + await expect(async () => { + const count = await catalogPage.getCatalogTiles().count(); + expect(count).toBeGreaterThan(0); + }).toPass(); + } else { + // If clear button doesn't appear, just clear the search manually + await catalogPage.clearSearchFilter(); + await expect(async () => { + const count = await catalogPage.getCatalogTiles().count(); + expect(count).toBeGreaterThan(0); + }).toPass(); + } + }); + + await test.step('Test category filter functionality', async () => { + await catalogPage.clickCategoryFilter('ai/machine learning'); + await expect(async () => { + const count = await catalogPage.getCatalogTiles().count(); + expect(count).toBeGreaterThan(0); + }).toPass(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/e2e/tests/olm/operator-install-global.spec.ts b/frontend/e2e/tests/olm/operator-install-global.spec.ts new file mode 100644 index 00000000000..66187b50709 --- /dev/null +++ b/frontend/e2e/tests/olm/operator-install-global.spec.ts @@ -0,0 +1,157 @@ +import { test, expect } from '../../fixtures'; +import { OperatorInstallPage } from '../../pages/operator-install-page'; +import { InstalledOperatorsPage } from '../../pages/installed-operators-page'; +import { OperatorDetailsPage, TestOperandProps } from '../../pages/operator-details-page'; +import { cleanupOperatorResources, cleanupAllOperatorsByPackageName } from '../../test-utils/operator-cleanup'; + +const testOperator = { + name: 'Data Grid', + operatorCardTestID: 'operator-Data Grid', + urlName: 'datagrid-operator', +}; + +const testOperand: TestOperandProps = { + name: 'Infinispan', + group: 'infinispan.org', + version: 'v1', + kind: 'Infinispan', + createActionID: 'list-page-create-dropdown-item-infinispan.org~v1~Infinispan', + exampleName: 'example-infinispan', +}; + +const operatorPackageName = 'datagrid'; +const globalNamespace = 'openshift-operators'; + +// Enhanced cleanup wrapper for Data Grid operator +async function cleanupDataGridOperatorResources(k8sClient: any) { + await cleanupOperatorResources(k8sClient, { + operatorPackageName, + operandPlural: 'infinispans', + testOperand, + namespace: globalNamespace, + }); +} + + +test.describe(`Globally installing "${testOperator.name}" operator in ${globalNamespace}`, { tag: ['@admin'] }, () => { + test.beforeEach(async ({ k8sClient, page }) => { + console.log('=== BEFORE EACH: Starting cleanup ==='); + + // First clean up cluster-scoped Operator resources that prevent reinstallation + try { + const operators = await k8sClient.listClusterCustomResources('operators.coreos.com', 'v1', 'operators'); + const dataGridOperators = operators.filter((op: any) => op.metadata.name.includes(operatorPackageName)); + + for (const operator of dataGridOperators) { + console.log(`Deleting cluster operator: ${operator.metadata.name}`); + await k8sClient.deleteClusterCustomResource('operators.coreos.com', 'v1', 'operators', operator.metadata.name); + } + } catch (error) { + console.log('Error cleaning up cluster operators:', error.message); + } + + // Then do aggressive cluster-wide cleanup of all operators for this package + await cleanupAllOperatorsByPackageName(k8sClient, operatorPackageName); + + // Then do namespace-specific cleanup + await cleanupDataGridOperatorResources(k8sClient); + + // Also clean up any test namespaces that might have conflicting OperatorGroups + try { + const namespaces = await k8sClient.listNamespaces(); + const testNamespaces = namespaces.filter((ns: any) => ns.metadata.name.startsWith('test-')); + + for (const testNs of testNamespaces) { + const nsName = (testNs as any)?.metadata?.name; + if (nsName) { + console.log(`Cleaning up test namespace: ${nsName}`); + await cleanupOperatorResources(k8sClient, { + operatorPackageName, + operandPlural: 'infinispans', + namespace: nsName, + }); + } + } + } catch (error) { + console.log('Error cleaning up test namespaces:', error.message); + } + + // Wait for cleanup to propagate + await page.waitForTimeout(5000); // Reduced from 15s to 5s + console.log('=== BEFORE EACH: Cleanup complete ==='); + }); + + test.afterEach(async ({ k8sClient }) => { + console.log('=== AFTER EACH: Safety cleanup (UI uninstall should have handled this) ==='); + + // Give UI uninstall time to complete first + await new Promise(resolve => setTimeout(resolve, 10000)); + + // Clean up cluster-scoped Operator resources first + try { + const operators = await k8sClient.listClusterCustomResources('operators.coreos.com', 'v1', 'operators'); + const dataGridOperators = operators.filter((op: any) => op.metadata.name.includes(operatorPackageName)); + + for (const operator of dataGridOperators) { + console.log(`[AfterEach] Deleting cluster operator: ${operator.metadata.name}`); + await k8sClient.deleteClusterCustomResource('operators.coreos.com', 'v1', 'operators', operator.metadata.name); + } + } catch (error) { + console.log('Error in afterEach cluster operator cleanup:', error.message); + } + + // Only clean up if UI uninstall failed to remove everything + await cleanupAllOperatorsByPackageName(k8sClient, operatorPackageName); + + console.log('=== AFTER EACH: Safety cleanup complete ==='); + }); + + test(`Globally installs ${testOperator.name} operator in ${globalNamespace} and creates ${testOperand.name} operand`, async ({ + page, + k8sClient, + cleanup, + }) => { + const installPage = new OperatorInstallPage(page); + const installedOperatorsPage = new InstalledOperatorsPage(page); + const operatorDetailsPage = new OperatorDetailsPage(page); + + await test.step('Install operator globally', async () => { + try { + await installPage.installOperatorGlobally(testOperator.name, testOperator.operatorCardTestID); + } catch (error) { + if (error.message?.includes('operator-Data Grid')) { + test.skip(true, 'Data Grid operator not available in this cluster environment'); + } + throw error; + } + }); + + await test.step('Verify operator installation succeeded', async () => { + await installedOperatorsPage.verifyOperatorInstallationSucceeded(testOperator.name); + }); + + await test.step('Navigate to operator details page and verify sections', async () => { + await installedOperatorsPage.navigateToOperatorDetails(testOperator.name, testOperator.urlName, globalNamespace); + await operatorDetailsPage.verifyDetailsPageSections(); + }); + + await test.step('Create operand instance', async () => { + await operatorDetailsPage.createOperand(testOperand, true); + await expect(page.getByTestId(testOperand.exampleName)).toBeVisible(); + }); + + await test.step('Verify operand exists and can navigate to details', async () => { + await operatorDetailsPage.verifyOperandExists(testOperand, true); + }); + + await test.step('Delete operand instance', async () => { + await operatorDetailsPage.deleteOperand(testOperand, true); + await operatorDetailsPage.verifyOperandNotExists(testOperand, true); + }); + + await test.step('Uninstall operator', async () => { + await operatorDetailsPage.uninstallOperator(); + await installedOperatorsPage.verifyOperatorNotExists(testOperator.name); + }); + }); +}); diff --git a/frontend/e2e/tests/olm/operator-install-single-namespace.spec.ts b/frontend/e2e/tests/olm/operator-install-single-namespace.spec.ts new file mode 100644 index 00000000000..9c75584d601 --- /dev/null +++ b/frontend/e2e/tests/olm/operator-install-single-namespace.spec.ts @@ -0,0 +1,199 @@ +import { test, expect } from '../../fixtures'; +import { OperatorInstallPage } from '../../pages/operator-install-page'; +import { InstalledOperatorsPage } from '../../pages/installed-operators-page'; +import { OperatorDetailsPage, TestOperandProps } from '../../pages/operator-details-page'; +import { cleanupOperatorResources, cleanupAllOperatorsByPackageName } from '../../test-utils/operator-cleanup'; +import { generateTestNamespace } from '../../test-utils/test-namespace'; + +const testOperator = { + name: 'Data Grid', + operatorCardTestID: 'operator-Data Grid', + urlName: 'datagrid-operator.v8.6.5', +}; + +const testOperand: TestOperandProps = { + name: 'Backup', + group: 'infinispan.org', + version: 'v1', + kind: 'Backup', + // createActionID removed - let it use the default create flow + exampleName: 'example-backup', +}; + +const operatorPackageName = 'datagrid'; +const globalNamespace = 'openshift-operators'; + +// Enhanced cleanup wrapper for Data Grid operator in single namespace tests +async function cleanupDataGridOperatorSingleNamespace(k8sClient: any) { + await cleanupOperatorResources(k8sClient, { + operatorPackageName, + operandPlural: 'backups', + testOperand, + namespace: globalNamespace, // Will also clean global namespace to be safe + }); +} + +test.describe(`Single Namespace Operator Installation - ${testOperator.name}`, { tag: ['@admin'] }, () => { + test.describe.configure({ timeout: 300_000 }); // 5 minutes instead of 15 + + test.beforeEach(async ({ k8sClient, page }) => { + console.log('=== SINGLE NAMESPACE BEFORE EACH: Starting cleanup ==='); + + // First do aggressive cluster-wide cleanup of all operators for this package + await cleanupAllOperatorsByPackageName(k8sClient, operatorPackageName); + + // Then do namespace-specific cleanup + await cleanupDataGridOperatorSingleNamespace(k8sClient); + + // Also clean up any test namespaces that might have conflicting OperatorGroups + try { + const namespaces = await k8sClient.listNamespaces(); + const testNamespaces = namespaces.filter((ns: any) => ns.metadata.name.startsWith('test-')); + + for (const testNs of testNamespaces) { + const nsName = (testNs as any)?.metadata?.name; + if (nsName) { + console.log(`Cleaning up test namespace: ${nsName}`); + await cleanupOperatorResources(k8sClient, { + operatorPackageName, + operandPlural: 'backups', + namespace: nsName, + }); + } + } + } catch (error) { + console.log('Error cleaning up test namespaces:', error.message); + } + + // Wait for cleanup to propagate + await page.waitForTimeout(5000); // Reduced from 15s to 5s + console.log('=== SINGLE NAMESPACE BEFORE EACH: Cleanup complete ==='); + }); + + test.afterEach(async ({ k8sClient }) => { + console.log('=== SINGLE NAMESPACE AFTER EACH: Starting verification ==='); + + // Just verify that the UI uninstall worked - don't force cleanup + // since this test includes UI-driven uninstall as part of the test + await new Promise(resolve => setTimeout(resolve, 5000)); // Give UI uninstall time to complete + + try { + const remaining = await k8sClient.listClusterCustomResources('operators.coreos.com', 'v1', 'operators'); + const stillThere = remaining.filter((op: any) => op.metadata.name?.includes(operatorPackageName)); + + if (stillThere.length > 0) { + console.log('⚠️ Note: Some operators still present after UI uninstall:', stillThere.map((op: any) => op.metadata.name)); + // Could optionally do cleanup here if UI uninstall failed, but let's see what happens first + } else { + console.log('✅ UI uninstall appears successful - no operators remaining'); + } + } catch (error) { + console.log('Could not verify cleanup state:', error.message); + } + + console.log('=== SINGLE NAMESPACE AFTER EACH: Verification complete ==='); + }); + + test(`Installs ${testOperator.name} operator in test namespace and manages ${testOperand.name} operand instance`, async ({ + page, + k8sClient, + cleanup, + }) => { + const installPage = new OperatorInstallPage(page); + const installedOperatorsPage = new InstalledOperatorsPage(page); + const operatorDetailsPage = new OperatorDetailsPage(page); + + const testNamespace = generateTestNamespace(); + + await test.step('Install operator in new test namespace', async () => { + try { + await installPage.installOperatorInNewNamespace( + testOperator.name, + testOperator.operatorCardTestID, + testNamespace, + ); + cleanup.trackNamespace(testNamespace); + } catch (error) { + if (error.message?.includes('operator-Data Grid')) { + test.skip(true, 'Data Grid operator not available in this cluster environment'); + } + throw error; + } + }); + + await test.step('Verify operator installation succeeded in test namespace', async () => { + await installedOperatorsPage.verifyOperatorInstallationSucceeded(testOperator.name); + }); + + await test.step('Navigate to operator details page and verify sections', async () => { + await installedOperatorsPage.navigateToOperatorDetails(testOperator.name, testOperator.urlName, testNamespace); + await operatorDetailsPage.verifyDetailsPageSections(); + }); + + await test.step('Verify operator is NOT installed globally (isolation test)', async () => { + // This is a key verification that distinguishes single namespace from global installation + await installedOperatorsPage.navigateToInstalledOperators(); + + // Switch to global namespace and verify operator is not there + await installedOperatorsPage.selectNamespace(globalNamespace); + + const emptyState = page.getByTestId('console-empty-state'); + await expect(emptyState.or(page.locator('[data-test="msg-box-title"]'))).toContainText( + /No Operators found|No results found/, + ); + }); + + await test.step('Navigate to operator details', async () => { + await installedOperatorsPage.navigateToInstalledOperators(); + await installedOperatorsPage.selectNamespace(testNamespace); + + // Wait for loading to complete after namespace switch + await expect(page.locator('.loading-skeleton--table')).not.toBeAttached({ timeout: 30_000 }); + + + // Wait for operator to appear in the new namespace + await expect(installedOperatorsPage.getOperatorRow(testOperator.name)).toBeVisible({ timeout: 60_000 }); + + await installedOperatorsPage.navigateToOperatorDetails(testOperator.name, testOperator.urlName, testNamespace); + await operatorDetailsPage.verifyDetailsPageSections(); + // await operatorDetailsPage.navigateToOperandTab(testOperand.name, false); + }); + + await test.step('Create operand', async () => { + await operatorDetailsPage.createOperand(testOperand, false); + await expect(page.getByTestId(testOperand.exampleName)).toBeVisible(); + }); + + await test.step('Navigate to operand details', async () => { + await operatorDetailsPage.clickOperandLink(testOperand.exampleName); + await expect(page).toHaveURL(new RegExp(`${testOperand.exampleName}$`)); + }); + + await test.step('Delete operand instance', async () => { + await operatorDetailsPage.deleteOperand(testOperand, false); + }); + + await test.step('Navigate back to operand instances and verify deletion', async () => { + await installedOperatorsPage.navigateToOperatorDetails(testOperator.name, testOperator.urlName, testNamespace); + await operatorDetailsPage.navigateToOperandTab(testOperand.name, false); + await operatorDetailsPage.verifyOperandNotExistsOnCurrentTab(testOperand.exampleName); + }); + + await test.step('Uninstall operator from namespace', async () => { + await operatorDetailsPage.uninstallOperator(); + await installedOperatorsPage.verifyOperatorNotExists(testOperator.name); + }); + + await test.step('Final cleanup - ensure no orphaned resources', async () => { + // Additional cleanup to ensure no orphaned resources + await cleanupOperatorResources(k8sClient, { + operatorPackageName, + operandPlural: 'backups', + testOperand, + namespace: testNamespace, + }); + }); + + // Note: The test namespace will be automatically cleaned up via cleanup.trackNamespace() + }); +}); diff --git a/frontend/e2e/tests/olm/operator-uninstall.spec.ts b/frontend/e2e/tests/olm/operator-uninstall.spec.ts new file mode 100644 index 00000000000..19f2a0bdb3e --- /dev/null +++ b/frontend/e2e/tests/olm/operator-uninstall.spec.ts @@ -0,0 +1,141 @@ +import { test, expect } from '../../fixtures'; +import { InstalledOperatorsPage } from '../../pages/installed-operators-page'; +import { OperatorDetailsPage, type TestOperandProps } from '../../pages/operator-details-page'; +import { OperatorInstallPage } from '../../pages/operator-install-page'; +import { ModalPage } from '../../pages/modal-page'; +import { generateTestNamespace } from '../../test-utils/test-namespace'; +import { createOperatorTestHooks, type OperatorTestConfig } from '../../test-utils/olm-test-cleanup'; + +const testOperator = { + name: 'Data Grid', + operatorCardTestID: 'operator-Data Grid', + urlName: 'datagrid-operator.v8.6.5', +}; + +const testOperand: TestOperandProps = { + name: 'Backup', + group: 'infinispan.org', + version: 'v1', + kind: 'Backup', + createActionID: 'list-page-create-dropdown-item-infinispan.org~v1~Backup', + exampleName: 'example-backup', +}; + +// Test configuration for shared cleanup +const testConfig: OperatorTestConfig = { + operatorName: testOperator.name, + operatorCardTestID: testOperator.operatorCardTestID, + packageName: 'datagrid', + operand: { + ...testOperand, + plural: 'backups', + }, + globalNamespace: 'openshift-operators', +}; + +// Create shared test hooks +const testHooks = createOperatorTestHooks(testConfig); + + +test.describe('Testing uninstall of Data Grid Operator', { tag: ['@admin'] }, () => { + test.describe.configure({ timeout: 300_000 }); // 5 minutes for operator operations + + // Use shared test hooks for consistent cleanup + test.beforeAll(testHooks.beforeAll); + + test.afterAll(testHooks.afterAll); + + + test(`Installs ${testOperator.name} Operator and ${testOperand.name} Instance, tests uninstall scenarios, then successfully uninstalls`, async ({ page, k8sClient, cleanup }) => { + const installPage = new OperatorInstallPage(page); + const installedOperatorsPage = new InstalledOperatorsPage(page); + const operatorDetailsPage = new OperatorDetailsPage(page); + const modalPage = new ModalPage(page); + + const testNamespace = generateTestNamespace(); + + await test.step('Install operator in new test namespace', async () => { + try { + await installPage.installOperatorInNewNamespace( + testOperator.name, + testOperator.operatorCardTestID, + testNamespace, + ); + cleanup.trackNamespace(testNamespace); + } catch (error) { + if (error?.message?.includes('operator-Data Grid')) { + test.skip(true, 'Data Grid operator not available in this cluster environment'); + } + throw error; + } + }); + + await test.step('Verify operator installation and create operand', async () => { + // Verify operator installation succeeded + await installedOperatorsPage.verifyOperatorInstallationSucceeded(testOperator.name); + + // Navigate to operator details page + await installedOperatorsPage.navigateToOperatorDetails(testOperator.name, testOperator.urlName, testNamespace); + + // Create operand (this will navigate to the correct tab automatically) + await operatorDetailsPage.createOperand(testOperand, false); + await expect(page.getByTestId(testOperand.exampleName)).toBeVisible(); + }); + + await test.step('Verify details page sections', async () => { + // Navigate back to operator details page + await installedOperatorsPage.navigateToOperatorDetails(testOperator.name, testOperator.urlName, testNamespace); + + // Verify operator details page sections exist + await operatorDetailsPage.verifyDetailsPageSections(); + }); + + await test.step('Test uninstall with "Cannot load Operands" error', async () => { + // Set up route interception to return error for operand list API (matching Cypress pattern) + await page.route('**/api/olm/list-operands**', route => { + route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ error: 'Failed to list operands' }) + }); + }); + + // Open uninstall modal without submitting + await operatorDetailsPage.uninstallOperator(false); + + // Verify error alert appears + await operatorDetailsPage.verifyUninstallAlert('Cannot load Operands'); + + // Cancel the modal + await operatorDetailsPage.cancelUninstall(); + + // Clear the route interception for next step + await page.unroute('**/api/olm/list-operands**'); + }); + + await test.step('Successfully uninstall operator (without operands)', async () => { + // Navigate back to operator details page to ensure clean state + await installedOperatorsPage.navigateToOperatorDetails(testOperator.name, testOperator.urlName, testNamespace); + + // Uninstall operator normally (without trying to delete operands since none exist) + await operatorDetailsPage.uninstallOperator(); + + // Verify operator no longer exists + await installedOperatorsPage.verifyOperatorNotExists(testOperator.name); + }); + + await test.step('Verify operand instance is deleted', async () => { + // Verify operand is deleted via K8s API (should throw 404) + await expect(async () => { + await k8sClient.getCustomResource( + testOperand.group, + testOperand.version, + testNamespace, + 'backups', + testOperand.exampleName + ); + }).rejects.toThrow(); + }); + + }); +}); diff --git a/frontend/e2e/tests/olm/packageserver-tabs.spec.ts b/frontend/e2e/tests/olm/packageserver-tabs.spec.ts new file mode 100644 index 00000000000..97532ae9748 --- /dev/null +++ b/frontend/e2e/tests/olm/packageserver-tabs.spec.ts @@ -0,0 +1,104 @@ +import { test, expect } from '../../fixtures'; +import { DetailsPage } from '../../pages/details-page'; +import { YamlEditorPage } from '../../pages/yaml-editor-page'; + +test.describe('packageserver PackageManifest tabs rendering', { tag: ['@admin'] }, () => { + const csvNamespace = 'openshift-operator-lifecycle-manager'; + const csvName = 'packageserver'; + const packageManifestName = '3scale-operator'; + const baseUrl = `/k8s/ns/${csvNamespace}/operators.coreos.com~v1alpha1~ClusterServiceVersion/${csvName}/packages.operators.coreos.com~v1~PackageManifest/${packageManifestName}`; + const sectionHeader = 'PackageManifest overview'; + + test('renders Details tab correctly', async ({ page }) => { + await test.step('Navigate to PackageManifest Details tab', async () => { + const detailsPage = new DetailsPage(page); + await detailsPage.navigateToDetailsUrl(baseUrl); + }); + + await test.step('Verify page title shows package name', async () => { + const detailsPage = new DetailsPage(page); + await expect(detailsPage.title).toContainText(packageManifestName); + }); + + await test.step('Verify Details section header exists', async () => { + const detailsPage = new DetailsPage(page); + await expect(detailsPage.getSectionHeader(sectionHeader)).toBeVisible(); + }); + }); + + test('renders YAML tab correctly', async ({ page }) => { + await test.step('Navigate to PackageManifest YAML tab', async () => { + const yamlEditor = new YamlEditorPage(page); + await yamlEditor.navigateToYamlUrl(`${baseUrl}/yaml`); + }); + + await test.step('Verify YAML contains package manifest metadata', async () => { + const yamlEditor = new YamlEditorPage(page); + const content = await yamlEditor.getEditorContent(); + expect(content).toContain(packageManifestName); + expect(content).toContain('PackageManifest'); + }); + }); + + test('renders Resources tab correctly', async ({ page }) => { + await test.step('Navigate to PackageManifest Resources tab', async () => { + const detailsPage = new DetailsPage(page); + await detailsPage.navigateToDetailsUrl(`${baseUrl}/resources`); + }); + + await test.step('Verify resource list is empty', async () => { + const detailsPage = new DetailsPage(page); + await expect(detailsPage.getEmptyState()).toBeVisible(); + }); + }); + + test('renders Events tab correctly', async ({ page }) => { + await test.step('Navigate to PackageManifest Events tab', async () => { + const detailsPage = new DetailsPage(page); + await detailsPage.navigateToDetailsUrl(`${baseUrl}/events`); + }); + + await test.step('Verify events stream component is empty', async () => { + const detailsPage = new DetailsPage(page); + await expect(detailsPage.getEmptyState()).toBeVisible(); + }); + }); + + test('allows navigation between tabs', async ({ page }) => { + const detailsPage = new DetailsPage(page); + const yamlEditor = new YamlEditorPage(page); + + await test.step('Start at Details tab', async () => { + await detailsPage.navigateToDetailsUrl(baseUrl); + }); + + await test.step('Navigate to YAML tab', async () => { + await detailsPage.selectTab('YAML'); + await yamlEditor.waitForEditorReady(); + await expect(page).toHaveURL(new RegExp('/yaml')); + }); + + await test.step('Navigate to Resources tab', async () => { + await detailsPage.selectTab('Resources'); + await detailsPage.waitForPageLoad(); + await expect(page).toHaveURL(new RegExp('/resources')); + await expect(detailsPage.getEmptyState()).toBeVisible(); + }); + + await test.step('Navigate to Events tab', async () => { + await detailsPage.selectTab('Events'); + await detailsPage.waitForPageLoad(); + await expect(page).toHaveURL(new RegExp('/events')); + await expect(detailsPage.getEmptyState()).toBeVisible(); + }); + + await test.step('Navigate back to Details tab', async () => { + await detailsPage.selectTab('Details'); + await detailsPage.waitForPageLoad(); + await expect(page).not.toHaveURL(new RegExp('/yaml')); + await expect(page).not.toHaveURL(new RegExp('/resources')); + await expect(page).not.toHaveURL(new RegExp('/events')); + await expect(detailsPage.getSectionHeader(sectionHeader)).toBeVisible(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/packages/console-shared/src/components/catalog/catalog-view/CatalogEmptyState.tsx b/frontend/packages/console-shared/src/components/catalog/catalog-view/CatalogEmptyState.tsx index d44f1332559..2e190bdb681 100644 --- a/frontend/packages/console-shared/src/components/catalog/catalog-view/CatalogEmptyState.tsx +++ b/frontend/packages/console-shared/src/components/catalog/catalog-view/CatalogEmptyState.tsx @@ -30,7 +30,12 @@ export const CatalogEmptyState: FC = ({ onClear }) => { - diff --git a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/catalog-source-details.cy.ts b/frontend/packages/operator-lifecycle-manager/integration-tests/tests/catalog-source-details.cy.ts deleted file mode 100644 index fac9e286cb2..00000000000 --- a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/catalog-source-details.cy.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { checkErrors, create, testName } from '@console/cypress-integration-tests/support'; -import { detailsPage } from '@console/cypress-integration-tests/views/details-page'; -import { modal } from '@console/cypress-integration-tests/views/modal'; -import { nav } from '@console/cypress-integration-tests/views/nav'; -import { testCatalogSource } from '../mocks'; - -const managedCatalogSource = { - name: 'redhat-operators', - displayName: 'Red Hat Operators', -}; - -describe(`Interacting with CatalogSource page`, () => { - before(() => { - cy.login(); - cy.createProjectWithCLI(testName); - create(testCatalogSource); - }); - - beforeEach(() => { - cy.log('navigate to Catalog Source page'); - nav.sidenav.clickNavLink(['Administration', 'Cluster Settings']); - cy.byLegacyTestID('horizontal-link-Configuration').click(); - cy.byTestID('loading-indicator').should('not.exist'); - cy.byLegacyTestID('OperatorHub').scrollIntoView().click(); - - // verfiy OperatorHub details page is open - detailsPage.sectionHeaderShouldExist('OperatorHub details'); - - // navigate to Catalog Sources list - cy.byLegacyTestID('horizontal-link-Sources').click(); - }); - - afterEach(() => { - checkErrors(); - }); - - after(() => { - cy.deleteProjectWithCLI(testName); - }); - - it(`renders details about the ${managedCatalogSource.name} catalog source`, () => { - cy.byLegacyTestID(managedCatalogSource.name).click(); - - // verfiy catalogSource details page is open - detailsPage.sectionHeaderShouldExist('CatalogSource details'); - - // verify catalogSource/redhat-operators' is READY - cy.byTestSelector('details-item-value__Status', { timeout: 300000 }).should( - 'have.text', - 'READY', - ); // 5 mins - - // validate Name field - cy.byTestSelector('details-item-label__Name').should('be.visible'); - cy.byTestSelector('details-item-value__Name').should('have.text', managedCatalogSource.name); - - // validate Status field - cy.byTestSelector('details-item-label__Status').should('be.visible'); - - // validate DisplayName field - cy.byTestSelector('details-item-label__Display name').should('be.visible'); - cy.byTestSelector('details-item-value__Display name').should( - 'have.text', - managedCatalogSource.displayName, - ); - - // validate RegistryPollInterval field - cy.byTestID('Registry poll interval').scrollIntoView().should('be.visible'); - cy.byTestSelector('details-item-value__Registry poll interval') - .scrollIntoView() - .should('be.visible'); - - // validate NumberOfOperators field - cy.byTestSelector('details-item-label__Number of Operators') - .scrollIntoView() - .should('be.visible'); - cy.byTestSelector('details-item-value__Number of Operators') - .scrollIntoView() - .should('be.visible'); - }); - - it(`lists all the package manifests for ${managedCatalogSource.name} under Operators tab`, () => { - cy.byLegacyTestID(managedCatalogSource.name).click(); - - // verfiy catalogSource details page is open - detailsPage.sectionHeaderShouldExist('CatalogSource details'); - - cy.byLegacyTestID('horizontal-link-Operators').click(); - cy.byTestID('PackageManifestTable').should('exist'); - }); - - it(`allows modifying registry poll interval on test catalog source`, () => { - cy.byLegacyTestID(testCatalogSource.metadata.name).click(); - - cy.byTestID('Registry poll interval-details-item__edit-button').click(); - cy.byTestID('registry-poll-interval-modal-title').should( - 'contain.text', - 'Edit registry poll interval', - ); - cy.byTestID('registry-poll-interval-dropdown').click(); - cy.byTestDropDownMenu('30m').should('be.visible').click(); - modal.submit(); - - // verify that registryPollInterval is updated - cy.byTestSelector('details-item-value__Registry poll interval').should('have.text', '30m'); - }); -}); diff --git a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/deprecated-operator-warnings.cy.ts b/frontend/packages/operator-lifecycle-manager/integration-tests/tests/deprecated-operator-warnings.cy.ts deleted file mode 100644 index 752d4a8ec76..00000000000 --- a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/deprecated-operator-warnings.cy.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { checkErrors, create, testName } from '@console/cypress-integration-tests/support'; -import { testDeprecatedCatalogSource, testDeprecatedSubscription } from '../mocks'; -import { operator } from '../views/operator.view'; - -const TIMEOUT = { timeout: 300000 }; -const testOperatorName = 'Kiali Community Operator'; -const testOperator = { - name: 'Kiali Operator', -}; -const deprecatedBadge = 'Deprecated'; -const deprecatedPackageMessage = 'package kiali is end of life'; -const deprecatedChannelMessage = 'channel alpha is no longer supported'; -const deprecatedVersionMessage = 'kiali-operator.v1.68.0 is deprecated'; -const DEPRECATED_OPERATOR_WARNING_BADGE_ID = 'deprecated-operator-warning-badge'; -const DEPRECATED_OPERATOR_WARNING_PACKAGE_ID = 'deprecated-operator-warning-package'; -const DEPRECATED_OPERATOR_WARNING_CHANNEL_ID = 'deprecated-operator-warning-channel'; -const DEPRECATED_OPERATOR_WARNING_VERSION_ID = 'deprecated-operator-warning-version'; - -describe('Deprecated operator warnings', () => { - const subscriptionName = testDeprecatedSubscription.metadata.name; - const subscriptionNamespace = testDeprecatedSubscription.metadata.namespace; - const csvName = testDeprecatedSubscription.spec.startingCSV; - const catalogSourceName = testDeprecatedCatalogSource.metadata.name; - const catalogSourceNamespace = testDeprecatedCatalogSource.metadata.namespace; - - const cleanupOperatorResources = () => { - // Delete subscription first to stop operator reconciliation - cy.exec( - `oc delete subscription ${subscriptionName} -n ${subscriptionNamespace} --ignore-not-found --wait=false`, - { failOnNonZeroExit: false, timeout: 60000 }, - ); - // Delete CSV to remove the operator - cy.exec( - `oc delete clusterserviceversion ${csvName} -n ${subscriptionNamespace} --ignore-not-found --wait=false`, - { failOnNonZeroExit: false, timeout: 60000 }, - ); - // Delete any InstallPlans related to the operator - cy.exec( - `oc delete installplan -n ${subscriptionNamespace} -l operators.coreos.com/${subscriptionName}.${subscriptionNamespace}= --ignore-not-found --wait=false`, - { failOnNonZeroExit: false, timeout: 60000 }, - ); - }; - - before(() => { - cy.login(); - // Clean up any existing resources from previous failed runs - cleanupOperatorResources(); - cy.exec( - `oc delete catalogsource ${catalogSourceName} -n ${catalogSourceNamespace} --ignore-not-found --wait=false`, - { failOnNonZeroExit: false, timeout: 60000 }, - ); - create(testDeprecatedCatalogSource); - }); - - after(() => { - cy.visit('/'); - // Clean up operator resources - cleanupOperatorResources(); - // Clean up catalog source - cy.exec( - `oc delete catalogsource ${catalogSourceName} -n ${catalogSourceNamespace} --ignore-not-found --wait=false`, - { failOnNonZeroExit: false, timeout: 60000 }, - ); - checkErrors(); - }); - - it('verify deprecated Operator warning badge on the Operator tile', () => { - cy.visit( - `/k8s/ns/${testDeprecatedCatalogSource.metadata.namespace}/operators.coreos.com~v1alpha1~CatalogSource/test-community-operator-deprecation`, - ); - cy.log('verify the test-community-operator-deprecation CatalogSource is in "READY" status'); - cy.byTestSelector('details-item-value__Status', TIMEOUT).should('have.text', 'READY'); - - cy.log('visit Software Catalog'); - cy.visit(`/catalog/ns/${testName}`); - cy.byTestID('tab operator').click(); - - cy.log('filter by the group name'); - cy.byTestID('source-community-operators-for-testing-deprecation').click(); - - cy.log('filter by the operator name'); - cy.byTestID('search-catalog').type(testOperatorName); - cy.get('.co-catalog-tile', TIMEOUT).its('length').should('eq', 1); - - cy.log('verify the Deprecated badge on Kiali Community Operator tile'); - cy.byTestID('Deprecated-badge').contains(deprecatedBadge).should('exist'); - }); - - it('verify deprecated Operator warnings in the Operator details panel', () => { - cy.visit( - `/catalog/ns/${testName}?catalogType=operator&keyword=kia&selectedId=kiali-test-community-operator-deprecation-openshift-marketplace&channel=stable&version=1.83.0`, - ); - cy.log('verify the deprecated operator badge exists'); - cy.byTestID('Deprecated-badge').contains(deprecatedBadge).should('exist'); - - cy.log('verify the package deprecation warning exists when viewing a deprecated operator'); - cy.byTestID('deprecated-operator-warning-package') - .contains(deprecatedPackageMessage) - .should('exist'); - }); - - it('verify deprecated channel warnings in the Operator details panel', () => { - cy.visit( - `/catalog/ns/${testName}?catalogType=operator&keyword=kia&selectedId=kiali-test-community-operator-deprecation-openshift-marketplace&channel=stable&version=1.83.0`, - ); - - cy.log('verify the channel deprecation warnings do not exist yet'); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_PACKAGE_ID) - .contains(deprecatedChannelMessage) - .should('not.exist'); - cy.byTestID('deprecated-operator-warning-channel-icon').should('not.exist'); - cy.log('verify the channel deprecation warning icon exists in the channel select menu'); - // force click because parent PF modal component causes button not to be "visible" - cy.byTestID('operator-channel-select-toggle').should('exist').click({ - force: true, - }); - cy.byTestID('deprecated-operator-warning-channel-icon').should('exist'); - // force click because parent PF modal component causes button not to be "visible" - cy.get('[data-test="channel-option-alpha"] > button').click({ force: true }); - - cy.log('verify the channel deprecation alert exists after selecting a deprecated channel'); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_CHANNEL_ID) - .contains(deprecatedChannelMessage) - .should('exist'); - }); - - it('verify deprecated version warnings in the Operator details panel', () => { - cy.visit( - `/catalog/ns/${testName}?catalogType=operator&keyword=kia&selectedId=kiali-test-community-operator-deprecation-openshift-marketplace&channel=stable&version=1.83.0`, - ); - - cy.log('verify the version deprecation warnings do not exist yet'); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_VERSION_ID) - .contains(deprecatedVersionMessage) - .should('not.exist'); - cy.byTestID('deprecated-operator-warning-version-icon').should('not.exist'); - cy.log('verify the version deprecation warning icon exists in the version select menu'); - // force click because parent PF modal component causes button not to be "visible" - cy.byTestID('operator-version-select-toggle').click({ - force: true, - }); - cy.byTestID('deprecated-operator-warning-version-icon').should('exist'); - // force click because parent PF modal component causes button not to be "visible" - cy.get('[data-test="version-option-kiali-operator.v1.68.0"] > button').click({ force: true }); - cy.log( - 'verify the version deprecation warning alert exists after selecting a deprecated version', - ); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_VERSION_ID) - .contains(deprecatedVersionMessage) - .should('exist'); - }); - - it('verify deprecated Operator warnings on Install Operator details page', () => { - cy.log('visit the Install Operator details page'); - cy.visit( - '/operatorhub/subscribe?pkg=kiali&catalog=test-community-operator-deprecation&catalogNamespace=openshift-marketplace&targetNamespace=undefined&channel=alpha&version=1.68.0', - ); - - cy.log('verify the Deprecated badge on Kiali Community Operator logo'); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_BADGE_ID).contains(deprecatedBadge).should('exist'); - - cy.log('verify the deprecation warning messages exists'); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_PACKAGE_ID) - .contains(deprecatedPackageMessage) - .should('exist'); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_CHANNEL_ID) - .contains(deprecatedChannelMessage) - .should('exist'); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_VERSION_ID) - .contains(deprecatedVersionMessage) - .should('exist'); - }); - - // Tests for deprecation warnings on INSTALLED operators - describe('Installed Operator deprecation warnings', () => { - before(() => { - const subscriptionYaml = JSON.stringify(testDeprecatedSubscription); - - cy.log('Install operator via CLI'); - cy.exec(`echo '${subscriptionYaml}' | oc apply -f -`, { timeout: 60000 }); - - cy.log('Wait for InstallPlan to be created'); - cy.exec( - `oc wait subscription/${subscriptionName} -n ${subscriptionNamespace} ` + - `--for=jsonpath='{.status.installPlanRef.name}' --timeout=120s`, - { timeout: 150000 }, - ); - - cy.log('Approve InstallPlan via CLI'); - // eslint-disable-next-line promise/catch-or-return - cy.exec( - `oc get installplan -n ${subscriptionNamespace} -o jsonpath=` + - `'{.items[?(@.spec.clusterServiceVersionNames[*]=="${csvName}")].metadata.name}'`, - { timeout: 60000 }, - ).then((result) => { - const installPlanName = result.stdout.trim(); - if (installPlanName) { - return cy.exec( - `oc patch installplan ${installPlanName} -n ${subscriptionNamespace} ` + - `--type merge -p '{"spec":{"approved":true}}'`, - { timeout: 60000 }, - ); - } - return cy.wrap(null); - }); - - cy.log('Wait for CSV success and deprecation conditions'); - cy.exec( - `oc wait csv/${csvName} -n ${subscriptionNamespace} ` + - `--for=jsonpath='{.status.phase}'=Succeeded --timeout=300s && ` + - `oc wait subscription/${subscriptionName} -n ${subscriptionNamespace} ` + - `--for=condition=PackageDeprecated --timeout=180s`, - { failOnNonZeroExit: false, timeout: 500000 }, - ); - }); - - it('displays deprecated badge on Installed Operators list page', () => { - cy.visit( - `/k8s/ns/${subscriptionNamespace}/operators.coreos.com~v1alpha1~ClusterServiceVersion`, - ); - operator.filterByName(testOperator.name); - cy.byTestOperatorRow(testOperator.name).should('exist'); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_BADGE_ID, TIMEOUT) - .should('exist') - .and('contain.text', deprecatedBadge); - }); - - it('displays deprecation warnings on CSV details page', () => { - cy.visit( - `/k8s/ns/${subscriptionNamespace}/operators.coreos.com~v1alpha1~ClusterServiceVersion/${csvName}`, - ); - cy.byLegacyTestID('horizontal-link-Details', { timeout: 60000 }).should('exist'); - - cy.byTestID(DEPRECATED_OPERATOR_WARNING_BADGE_ID, TIMEOUT).should( - 'contain.text', - deprecatedBadge, - ); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_PACKAGE_ID, TIMEOUT).should( - 'contain.text', - deprecatedPackageMessage, - ); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_CHANNEL_ID, TIMEOUT).should( - 'contain.text', - deprecatedChannelMessage, - ); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_VERSION_ID, TIMEOUT).should( - 'contain.text', - deprecatedVersionMessage, - ); - }); - - it('displays deprecation warnings on CSV subscription tab', () => { - cy.visit( - `/k8s/ns/${subscriptionNamespace}/operators.coreos.com~v1alpha1~ClusterServiceVersion/${csvName}/subscription`, - ); - cy.byLegacyTestID('horizontal-link-Subscription', { timeout: 60000 }).should('exist'); - - cy.byTestID(DEPRECATED_OPERATOR_WARNING_PACKAGE_ID, TIMEOUT).should( - 'contain.text', - deprecatedPackageMessage, - ); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_CHANNEL_ID, TIMEOUT).should( - 'contain.text', - deprecatedChannelMessage, - ); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_VERSION_ID, TIMEOUT).should( - 'contain.text', - deprecatedVersionMessage, - ); - cy.byTestID('deprecated-operator-warning-subscription-update-icon', TIMEOUT).should('exist'); - - cy.byTestID('subscription-channel-update-button', TIMEOUT).should('not.be.disabled').click(); - cy.get('.pf-v6-c-modal-box', { timeout: 30000 }).should('be.visible'); - cy.byTestID('kiali-operator.v1.83.0').should('exist'); - }); - }); -}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/clusterserviceversion.tsx b/frontend/packages/operator-lifecycle-manager/src/components/clusterserviceversion.tsx index d7a5de3124e..0fa96226a07 100644 --- a/frontend/packages/operator-lifecycle-manager/src/components/clusterserviceversion.tsx +++ b/frontend/packages/operator-lifecycle-manager/src/components/clusterserviceversion.tsx @@ -390,6 +390,7 @@ export const ClusterServiceVersionTableRow = withFallback = ({ <> @@ -119,7 +120,12 @@ const EditDefaultSourcesModal: FC = ({ > {t('Save')} - diff --git a/frontend/packages/operator-lifecycle-manager/src/components/modals/uninstall-operator-modal.tsx b/frontend/packages/operator-lifecycle-manager/src/components/modals/uninstall-operator-modal.tsx index 217fb1a0ddb..cecc036b74d 100644 --- a/frontend/packages/operator-lifecycle-manager/src/components/modals/uninstall-operator-modal.tsx +++ b/frontend/packages/operator-lifecycle-manager/src/components/modals/uninstall-operator-modal.tsx @@ -395,6 +395,7 @@ export const UninstallOperatorModal: FC = ({ @@ -445,7 +446,12 @@ export const UninstallOperatorModal: FC = ({ > {isSubmitFinished ? t('OK') : t('Uninstall')} - diff --git a/frontend/packages/operator-lifecycle-manager/src/components/registry-poll-interval-details.tsx b/frontend/packages/operator-lifecycle-manager/src/components/registry-poll-interval-details.tsx index e59b8415627..b8ce29fe3d5 100644 --- a/frontend/packages/operator-lifecycle-manager/src/components/registry-poll-interval-details.tsx +++ b/frontend/packages/operator-lifecycle-manager/src/components/registry-poll-interval-details.tsx @@ -31,6 +31,7 @@ const getPollIntervals = (selected: string): SimpleSelectOption[] => { content: interval, value: interval, selected: selected === interval, + 'data-test': `dropdown-menu-${interval}`, 'data-test-dropdown-menu': interval, })); }; @@ -131,6 +132,7 @@ export const RegistryPollIntervalDetailItem: FC