diff --git a/frontend/e2e/clients/kubernetes-client.ts b/frontend/e2e/clients/kubernetes-client.ts index 72c4a11f890..f21e2848d7a 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; @@ -569,6 +577,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, @@ -586,6 +628,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, @@ -605,6 +667,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 112b6d14398..4f70b4588fe 100644 --- a/frontend/e2e/pages/details-page.ts +++ b/frontend/e2e/pages/details-page.ts @@ -91,4 +91,16 @@ export class DetailsPage extends BasePage { this.page.getByRole('button', { name: 'Delete', exact: true }), ); } + 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 index 5eeb6ec9295..bbcfe162f91 100644 --- a/frontend/e2e/pages/installed-operators-page.ts +++ b/frontend/e2e/pages/installed-operators-page.ts @@ -1,8 +1,18 @@ 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 (legacy method from HEAD) + */ async navigateTo(namespace?: string): Promise { const path = namespace ? `/k8s/ns/${namespace}/operators.coreos.com~v1alpha1~ClusterServiceVersion` @@ -10,24 +20,256 @@ export class InstalledOperatorsPage extends BasePage { await this.goTo(path); } - getOperatorRow(displayName: string): Locator { - return this.page - .locator('tr') - .filter({ has: this.page.getByTestId(`operator-row-${displayName}`) }); + /** + * 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 = 12; // 1 minute worth of 5-second polls (reduced for faster feedback) + + 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 (${maxAttempts} attempts)`); + } + + /** + * 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(); + + // Wait for loading to complete + await expect(this.page.locator('.loading-skeleton--table')).not.toBeAttached({ timeout: 30_000 }); + + await this.filterByName(operatorName); + 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); + + // Wait for loading to complete + await expect(this.page.locator('.loading-skeleton--table')).not.toBeAttached({ timeout: 30_000 }); + + // Wait for the page to be ready with either operators or empty state + try { + // Try to wait for the name filter input to be available (when operators exist) + await expect(this.nameFilterInput).toBeVisible({ timeout: 10_000 }); + await this.filterByName(operatorName); + } catch (error) { + // If no filter input, check for empty state (no operators in this namespace) + const emptyState = this.page.getByTestId('console-empty-state'); + await expect(emptyState.or(this.page.locator('[data-test="msg-box-title"]'))).toBeVisible({ timeout: 10_000 }); + console.log(`No operators found in namespace ${namespace} - verification passed`); + return; + } + + // Wait for loading to complete after filtering + await expect(this.page.locator('.loading-skeleton--table')).not.toBeAttached({ timeout: 30_000 }); + + 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 exactly matches our namespace text + const namespaceOption = this.page.getByTestId('dropdown-menu-item-link').filter({ hasText: new RegExp(`^${namespace.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`) }); + await this.robustClick(namespaceOption); + + await expect(this.page.getByTestId('namespace-bar-dropdown')).toHaveText(new RegExp(namespace.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + } + + getPageHeading(): Locator { + return this.pageHeading; + } + + getNameFilterInput(): Locator { + return this.nameFilterInput; } + /** + * Get compatible indicator (from HEAD version) + */ getCompatibleIndicator(displayName: string): Locator { return this.getOperatorRow(displayName).getByTestId('cluster-compatibility-compatible'); } + /** + * Get incompatible indicator (from HEAD version) + */ getIncompatibleIndicator(displayName: string): Locator { return this.getOperatorRow(displayName).getByTestId('cluster-compatibility-incompatible'); } + /** + * Get support phase badge (from HEAD version) + */ getSupportPhaseBadge(displayName: string): Locator { return this.getOperatorRow(displayName).getByTestId('support-phase-badge'); } + /** + * Get self support badge (from HEAD version) + */ getSelfSupportBadge(displayName: string): Locator { return this.getOperatorRow(displayName).getByTestId('support-phase-self-support'); } 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..4e369d4c6ba --- /dev/null +++ b/frontend/e2e/pages/operator-details-page.ts @@ -0,0 +1,330 @@ +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; + exampleName: string; + createActionID?: string; // Optional - for operators with multiple operand types +} + +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: 30_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: 5_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: 5_000 }); + await this.detailsPage.selectTab('All instances'); + } else { + // For single namespace, try common tab variations + try { + await this.detailsPage.selectTab(operandName); + } catch (error) { + // If operand name tab doesn't exist, try "All instances" as fallback + console.log(`Tab ${operandName} not found, trying "All instances"`); + await expect(this.page.getByTestId('horizontal-link-All instances')).toBeVisible({ timeout: 5_000 }); + await this.detailsPage.selectTab('All instances'); + } + } + } + + /** + * Create operand instance + */ + async createOperand(testOperand: TestOperandProps, isGlobal: boolean = true): Promise { + const { exampleName, createActionID } = testOperand; + + await this.navigateToOperandTab(testOperand.name, isGlobal); + + // Wait for the page to load and create button to be visible + await expect(this.createItemButton).toBeVisible({ timeout: 30_000 }); + + // Verify operand doesn't already exist + await expect(this.getOperandLink(exampleName)).not.toBeAttached(); + + // Click create button + await this.robustClick(this.createItemButton); + + // If createActionID is provided, select it from the dropdown + if (createActionID) { + console.log(`Selecting create action: ${createActionID}`); + const dropdownOption = this.page.getByTestId(createActionID); + await expect(dropdownOption).toBeVisible({ timeout: 10_000 }); + await this.robustClick(dropdownOption); + } + + // Verify we're on the create form + await expect(this.page).toHaveURL(/~new/, { timeout: 30_000 }); + + // 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: 30_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(url => url.pathname.endsWith(`/${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(url => url.pathname.endsWith(`/${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(); + } + + /** + * 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: 30_000 }); + + if (submit) { + await this.modalPage.submit(); + await this.modalPage.waitForClosed(); + } + } + + /** + * Uninstall operator with all operands + */ + async uninstallOperatorWithOperands(deleteOperands: boolean = false): 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: 30_000 }); + + // Check delete all operands option if it exists and is requested + if (deleteOperands) { + console.log('๐Ÿ” Looking for delete-all-operands checkbox...'); + const deleteAllOperandsCheckbox = this.page.getByTestId('delete-all-operands'); + try { + await expect(deleteAllOperandsCheckbox).toBeVisible({ timeout: 5_000 }); + console.log('โœ… Found delete-all-operands checkbox, clicking it...'); + await deleteAllOperandsCheckbox.click(); + console.log('โœ… Successfully clicked delete-all-operands checkbox'); + } catch (error) { + console.log('โน๏ธ No delete-all-operands checkbox found - this operator may only have one operand'); + console.log('โน๏ธ Continuing with uninstall without checkbox...'); + } + } else { + console.log('โน๏ธ Skipping delete-all-operands checkbox (deleteOperands = false)'); + } + + 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..2b40e9b2b1d --- /dev/null +++ b/frontend/e2e/pages/operator-install-page.ts @@ -0,0 +1,178 @@ +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: 30_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: 30_000 }); + + if (useOperatorRecommended) { + await this.operatorRecommendedRadio.check(); + } else { + // Check if select namespace radio exists with timeout + try { + await this.selectNamespaceRadio.waitFor({ state: 'visible', timeout: 5_000 }); + await this.selectNamespaceRadio.check(); + } catch (error) { + // Element not available within timeout, continue without checking it + } + + // 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: 30_000 }); + + // Check if select namespace radio exists with timeout + try { + await this.selectNamespaceRadio.waitFor({ state: 'visible', timeout: 5_000 }); + await this.selectNamespaceRadio.check(); + } catch (error) { + // Element not available within timeout, continue without checking it + } + + // 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 4fcb249c714..472f7905c31 100644 --- a/frontend/e2e/pages/yaml-editor-page.ts +++ b/frontend/e2e/pages/yaml-editor-page.ts @@ -119,4 +119,20 @@ export class YamlEditorPage extends BasePage { }); await this.robustClick(viewDetailsButton); } + + async getEditorContent(): Promise { + return this.page.evaluate(() => { + // Check if Monaco is initialized before accessing editor API + if (!(window as any).monaco?.editor?.getModels) { + return ''; + } + 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..4253af06f93 --- /dev/null +++ b/frontend/e2e/test-utils/cluster-cleanup.ts @@ -0,0 +1,197 @@ +/** + * 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 + const namespaces = await k8sClient.listNamespaces(); + const testNamespaces = namespaces.filter((ns: any) => + ns.metadata.name.startsWith('test-') + ); + + 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..363e68efa0e --- /dev/null +++ b/frontend/e2e/test-utils/olm-cleanup.ts @@ -0,0 +1,150 @@ +/** + * 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}`); + try { + // Force delete by stripping finalizers and retrying deletion + await k8sClient.patchClusterCustomResource( + 'operators.coreos.com', + 'v1', + 'operators', + operator.metadata.name, + [{ op: 'replace', path: '/metadata/finalizers', value: [] }] + ); + await k8sClient.deleteClusterCustomResource( + 'operators.coreos.com', + 'v1', + 'operators', + operator.metadata.name + ); + console.log(`โœ… Force deleted Operator ${operator.metadata.name}`); + } catch (forceError) { + console.log(`โŒ Force delete failed for Operator ${operator.metadata.name}: ${forceError.message}`); + } + } 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} ---`); + + const namespaces = await k8sClient.listNamespaces(); + const matchingNamespaces = namespaces.filter((ns: any) => + ns.metadata.name.startsWith('test-') + ); + + console.log(`Found ${matchingNamespaces.length} matching namespaces`); + + for (const namespace of matchingNamespaces) { + const nsName = namespace.metadata.name; + if (dryRun) { + console.log(`[DRY RUN] Would delete namespace ${nsName}`); + } else { + try { + await k8sClient.deleteNamespace(nsName); + console.log(`โœ… Deleted namespace ${nsName}`); + } catch (error) { + console.log(`โŒ Failed to delete namespace ${nsName}: ${error.message}`); + } + } + } + } + + 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-' : namespace, + }); +} \ 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..94e692763f7 --- /dev/null +++ b/frontend/e2e/test-utils/olm-test-cleanup.ts @@ -0,0 +1,205 @@ +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 with polling + console.log('โณ Waiting for cleanup to propagate...'); + const remainingOperators = await waitForOperatorCleanup(k8sClient, packageName, 30_000); + + // Force-delete only the remaining stragglers + if (remainingOperators.length > 0) { + 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-')); + }); + + 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 + */ +/** + * Poll for operator cleanup completion with timeout + */ +async function waitForOperatorCleanup(k8sClient: any, packageName: string, timeoutMs = 30_000): Promise { + const startTime = Date.now(); + let remainingOperators: any[] = []; + + while (Date.now() - startTime < timeoutMs) { + try { + const allOperators = await k8sClient.listClusterCustomResources('operators.coreos.com', 'v1', 'operators'); + remainingOperators = allOperators.filter((op: any) => op.metadata.name?.includes(packageName)); + + if (remainingOperators.length === 0) { + console.log(`โœ… All ${packageName} operators cleaned up successfully`); + return []; + } + + console.log(`โณ Still waiting for ${remainingOperators.length} ${packageName} operators to be cleaned up...`); + await new Promise(resolve => setTimeout(resolve, 2_000)); // Poll every 2 seconds + } catch (error) { + console.log(`Error checking operator cleanup status:`, error.message); + await new Promise(resolve => setTimeout(resolve, 2_000)); + } + } + + return remainingOperators; +} + +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 ${matchingOperators.length} remaining ${packageName} operators that need force deletion:`, + 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); + } + } + } + } 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 waitForOperatorCleanup(k8sClient, config.packageName, 15_000); + 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 with polling + await waitForOperatorCleanup(k8sClient, config.packageName, 15_000); + 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..d1104cd2c05 --- /dev/null +++ b/frontend/e2e/test-utils/operator-cleanup.ts @@ -0,0 +1,728 @@ +import type { TestOperandProps } from '../pages/operator-details-page'; + +export interface OperatorCleanupOptions { + operatorPackageName: string; + operandPlural?: string; + testOperand?: TestOperandProps; + namespace: string; +} + +/** + * Quick check if any operator resources exist - if not, skip all the heavy scanning + */ +async function quickOperatorCheck(k8sClient: any, operatorPackageName: string): Promise { + try { + // Quick check for cluster operator + const operators = await k8sClient.listClusterCustomResources('operators.coreos.com', 'v1', 'operators'); + const hasOperator = operators.some((op: any) => + op.metadata.name?.includes(operatorPackageName) || + op.spec?.packageName === operatorPackageName + ); + + if (hasOperator) { + console.log(`๐Ÿ” Found existing ${operatorPackageName} operator resources - will clean them up`); + return true; + } + + // Quick check just openshift-operators namespace for subscriptions + try { + const subscriptions = await k8sClient.listCustomResources('operators.coreos.com', 'v1alpha1', 'openshift-operators', 'subscriptions'); + const hasSubscription = subscriptions.some((sub: any) => + sub.metadata.name?.includes(operatorPackageName) || + sub.spec?.name === operatorPackageName + ); + + if (hasSubscription) { + console.log(`๐Ÿ” Found existing ${operatorPackageName} subscriptions - will clean them up`); + return true; + } + } catch (error) { + // Ignore errors + } + + console.log(`โœ… No existing ${operatorPackageName} resources found - cleanup not needed`); + return false; + } catch (error) { + console.log(`Error during quick check: ${error.message}`); + return true; // If we can't check, assume cleanup is needed + } +} + +/** + * Proper operator cleanup following OpenShift documentation: + * 1. Find currentCSV from subscription + * 2. Delete subscription + * 3. Delete CSV using currentCSV value + */ +export async function cleanupAllOperatorsByPackageName(k8sClient: any, operatorPackageName: string): Promise { + console.log(`๐Ÿงน Checking for ${operatorPackageName} operators to clean up...`); + + // Quick check first - if nothing to clean, skip all the heavy work + const needsCleanup = await quickOperatorCheck(k8sClient, operatorPackageName); + if (!needsCleanup) { + console.log(`โœ… No cleanup needed for ${operatorPackageName}`); + return true; + } + + console.log(`๐Ÿงน Starting cleanup of ${operatorPackageName} operators...`); + + 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 with broader search + const matchingSubscriptions = subscriptions.filter((sub: any) => { + return ( + sub.metadata.name === operatorPackageName || + sub.spec?.name === operatorPackageName + ); + }); + + for (const subscription of matchingSubscriptions) { + console.log(`Found subscription ${subscription.metadata.name} in namespace ${nsName}`); + + // Step 1: Get the currentCSV from the subscription + const currentCSV = subscription.status?.currentCSV; + if (currentCSV) { + console.log(` currentCSV: ${currentCSV}`); + } + + // Step 2: Delete the subscription + console.log(` Deleting subscription ${subscription.metadata.name}`); + await k8sClient.deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + nsName, + 'subscriptions', + subscription.metadata.name, + ); + + // Step 3: Delete the CSV using the currentCSV value + if (currentCSV) { + try { + console.log(` Deleting ClusterServiceVersion ${currentCSV}`); + await k8sClient.deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + nsName, + 'clusterserviceversions', + currentCSV, + ); + console.log(` โœ… Successfully deleted CSV ${currentCSV}`); + } catch (csvError) { + console.log(` โš ๏ธ Could not delete CSV ${currentCSV}: ${csvError.message}`); + } + } else { + console.log(` โš ๏ธ No currentCSV found for subscription ${subscription.metadata.name}`); + } + } + } catch (error) { + console.log(` Error cleaning up namespace ${nsName}: ${error.message}`); + } + } + + // Also clean up cluster-scoped Operator resources that can prevent reinstallation + console.log('๐Ÿงน Cleaning up cluster-scoped Operator resources...'); + try { + const operators = await k8sClient.listClusterCustomResources('operators.coreos.com', 'v1', 'operators'); + const matchingOperators = operators.filter((op: any) => + op.metadata.name?.includes(operatorPackageName) || + op.spec?.packageName === operatorPackageName + ); + + for (const operator of matchingOperators) { + console.log(` Deleting cluster Operator: ${operator.metadata.name}`); + try { + await k8sClient.deleteClusterCustomResource('operators.coreos.com', 'v1', 'operators', operator.metadata.name); + console.log(` โœ… Successfully deleted cluster Operator ${operator.metadata.name}`); + } catch (error) { + console.log(` โš ๏ธ Could not delete cluster Operator ${operator.metadata.name}: ${error.message}`); + } + } + } catch (error) { + console.log(`Error cleaning cluster operators: ${error.message}`); + } + + // Give OLM a moment to clean up any remaining resources + console.log('Waiting 5 seconds for OLM to clean up remaining resources...'); + await new Promise(resolve => setTimeout(resolve, 5000)); + + // Quick verification that cleanup worked + const stillExists = await quickOperatorCheck(k8sClient, operatorPackageName); + if (stillExists) { + console.log('โš ๏ธ Some operator resources may still exist after cleanup'); + } else { + console.log('โœ… Cleanup verification: no operator resources found'); + } + + } catch (error) { + console.log('Error during proper operator cleanup:', error.message); + return false; + } + + console.log('โœ… cleanupAllOperatorsByPackageName FINISHED'); + return true; +} + +/** + * Force cleanup for when normal cleanup doesn't work - removes finalizers and force deletes + */ +export async function forceCleanupAllOperatorsByPackageName(k8sClient: any, operatorPackageName: string): Promise { + console.log(`๐Ÿ”ฅ Checking if force cleanup is needed for ${operatorPackageName}...`); + + // Quick check first - if nothing to clean, skip the force cleanup + const needsCleanup = await quickOperatorCheck(k8sClient, operatorPackageName); + if (!needsCleanup) { + console.log(`โœ… No force cleanup needed for ${operatorPackageName}`); + return true; + } + + console.log(`๐Ÿ”ฅ Starting force cleanup of ${operatorPackageName} operators...`); + + try { + // Force clean cluster-scoped Operator resources + const operators = await k8sClient.listClusterCustomResources('operators.coreos.com', 'v1', 'operators'); + const matchingOperators = operators.filter((op: any) => + op.metadata.name?.includes(operatorPackageName) || + op.spec?.packageName === operatorPackageName + ); + + for (const operator of matchingOperators) { + console.log(`๐Ÿ”ฅ Force deleting cluster Operator: ${operator.metadata.name}`); + try { + // Strip finalizers first + await k8sClient.patchClusterCustomResource( + 'operators.coreos.com', + 'v1', + 'operators', + operator.metadata.name, + [{ op: 'replace', path: '/metadata/finalizers', value: [] }] + ); + console.log(` Stripped finalizers from ${operator.metadata.name}`); + + // Then delete + await k8sClient.deleteClusterCustomResource('operators.coreos.com', 'v1', 'operators', operator.metadata.name); + console.log(` โœ… Force deleted cluster Operator ${operator.metadata.name}`); + } catch (error) { + console.log(` โš ๏ธ Could not force delete cluster Operator ${operator.metadata.name}: ${error.message}`); + } + } + + // Force clean subscriptions and CSVs in all namespaces + const namespaces = await k8sClient.listNamespaces(); + for (const namespace of namespaces) { + const nsName = namespace?.metadata?.name; + if (!nsName) continue; + + try { + // Force clean CSVs first + const csvs = await k8sClient.listCustomResources('operators.coreos.com', 'v1alpha1', nsName, 'clusterserviceversions'); + const matchingCSVs = csvs.filter((csv: any) => + csv.metadata.name?.includes(operatorPackageName) || + csv.spec?.displayName?.includes(operatorPackageName) + ); + + for (const csv of matchingCSVs) { + console.log(`๐Ÿ”ฅ Force deleting CSV: ${csv.metadata.name} in ${nsName}`); + try { + await k8sClient.deleteCustomResource('operators.coreos.com', 'v1alpha1', nsName, 'clusterserviceversions', csv.metadata.name); + console.log(` โœ… Force deleted CSV ${csv.metadata.name}`); + } catch (error) { + console.log(` โš ๏ธ Could not force delete CSV ${csv.metadata.name}: ${error.message}`); + } + } + + // Force clean subscriptions + const subscriptions = await k8sClient.listCustomResources('operators.coreos.com', 'v1alpha1', nsName, 'subscriptions'); + const matchingSubs = subscriptions.filter((sub: any) => + sub.metadata.name?.includes(operatorPackageName) || + sub.spec?.name?.includes(operatorPackageName) + ); + + for (const subscription of matchingSubs) { + console.log(`๐Ÿ”ฅ Force deleting subscription: ${subscription.metadata.name} in ${nsName}`); + try { + await k8sClient.deleteCustomResource('operators.coreos.com', 'v1alpha1', nsName, 'subscriptions', subscription.metadata.name); + console.log(` โœ… Force deleted subscription ${subscription.metadata.name}`); + } catch (error) { + console.log(` โš ๏ธ Could not force delete subscription ${subscription.metadata.name}: ${error.message}`); + } + } + } catch (error) { + // Ignore permission errors + } + } + + console.log('Waiting 10 seconds for force cleanup to propagate...'); + await new Promise(resolve => setTimeout(resolve, 10000)); + + // Final verification + const stillExists = await quickOperatorCheck(k8sClient, operatorPackageName); + if (stillExists) { + console.log('โš ๏ธ Some operator resources may still exist after force cleanup'); + } else { + console.log('โœ… Force cleanup verification: no operator resources found'); + } + + } catch (error) { + console.log('Error during force cleanup:', error.message); + return false; + } + + console.log('๐Ÿ”ฅ forceCleanupAllOperatorsByPackageName FINISHED'); + return true; +} + +/** + * Nuclear option - delete CRDs first, then the operator (this was the missing piece!) + */ +export async function deleteStuckOperator(k8sClient: any, operatorName: string, crdPattern: string): Promise { + console.log(`๐Ÿ’ฃ Checking for stuck ${operatorName} operator...`); + + // Quick check first + try { + await k8sClient.getClusterCustomResource('operators.coreos.com', 'v1', 'operators', operatorName); + console.log(`๐Ÿ’ฃ Found stuck ${operatorName} operator - starting nuclear cleanup...`); + } catch (error) { + if (error.message?.includes('404')) { + console.log(`โœ… No stuck ${operatorName} operator found - nuclear cleanup not needed`); + return true; + } else { + console.log(`โŒ Error checking for stuck ${operatorName} operator: ${error.message} - aborting nuclear cleanup`); + return false; + } + } + + try { + // STEP 1: Check which CRDs actually exist and delete them + console.log(`Checking for ${crdPattern} CRDs...`); + try { + const allCRDs = await k8sClient.listClusterCustomResources('apiextensions.k8s.io', 'v1', 'customresourcedefinitions'); + const operatorCRDs = allCRDs.filter((crd: any) => crd.metadata.name?.endsWith(crdPattern)); + + if (operatorCRDs.length === 0) { + console.log(`No ${crdPattern} CRDs found - skipping CRD deletion`); + } else { + console.log(`Found ${operatorCRDs.length} ${crdPattern} CRDs to delete`); + for (const crd of operatorCRDs) { + try { + console.log(`Deleting CRD: ${crd.metadata.name}`); + await k8sClient.deleteClusterCustomResource('apiextensions.k8s.io', 'v1', 'customresourcedefinitions', crd.metadata.name); + console.log(`โœ… Deleted CRD: ${crd.metadata.name}`); + } catch (error) { + console.log(`Could not delete CRD ${crd.metadata.name}: ${error.message}`); + } + } + } + } catch (error) { + console.log(`Error checking for CRDs: ${error.message}`); + } + + // Wait for CRD deletions to propagate + console.log('Waiting for CRD deletions to propagate...'); + await new Promise(resolve => setTimeout(resolve, 5000)); + + // STEP 2: Now delete the operator (should work now that CRDs are gone) + console.log('Now deleting the operator...'); + await k8sClient.deleteClusterCustomResource('operators.coreos.com', 'v1', 'operators', operatorName); + console.log(`โœ… Successfully deleted ${operatorName}`); + + // Wait and verify it's gone + await new Promise(resolve => setTimeout(resolve, 3000)); + + try { + await k8sClient.getClusterCustomResource('operators.coreos.com', 'v1', 'operators', operatorName); + console.log('โš ๏ธ Operator still exists after deletion attempt'); + } catch (error) { + if (error.message?.includes('404')) { + console.log(`โœ… Confirmed: ${operatorName} is gone`); + } else { + console.log(`Verification error: ${error.message}`); + } + } + } catch (error) { + console.log(`Error during nuclear cleanup: ${error.message}`); + + // Try force deletion by stripping finalizers from operator + try { + console.log('๐Ÿ’ฃ Trying to strip operator finalizers...'); + await k8sClient.patchClusterCustomResource( + 'operators.coreos.com', + 'v1', + 'operators', + operatorName, + [{ op: 'replace', path: '/metadata/finalizers', value: [] }] + ); + console.log('Stripped operator finalizers, retrying deletion...'); + + await k8sClient.deleteClusterCustomResource('operators.coreos.com', 'v1', 'operators', operatorName); + console.log(`โœ… Successfully force deleted ${operatorName}`); + } catch (forceError) { + console.log(`Force deletion also failed: ${forceError.message}`); + } + } + + console.log(`๐Ÿ’ฃ deleteStuckOperator FINISHED for ${operatorName}`); + return true; +} + +/** + * Shared cleanup logic for operator tests - handles both beforeEach (aborted runs) and afterEach (UI uninstall verification) + */ +export async function operatorTestCleanup( + k8sClient: any, + options: { + operatorPackageName: string; + operandPlural: string; + testOperand: any; + targetNamespace: string; + crdPatterns?: string[]; // CRD patterns to clean up (e.g., ['.infinispan.org']) + waitForUiUninstall?: boolean; // Optional delay before cleanup for afterEach + uiUninstallTimeoutMs?: number; + } +): Promise { + const { + operatorPackageName, + operandPlural, + testOperand, + targetNamespace, + crdPatterns = [], + waitForUiUninstall = false, + uiUninstallTimeoutMs = 10_000 // Reduced default since we're just giving UI time + } = options; + + try { + if (waitForUiUninstall) { + // Give UI uninstall a brief moment to start, then proceed with cleanup anyway + console.log(`โณ Giving UI uninstall ${uiUninstallTimeoutMs / 1000}s to start, then cleaning up remaining resources...`); + await new Promise(resolve => setTimeout(resolve, uiUninstallTimeoutMs)); + } + + // Clean up subscription and CSV resources (standard OLM cleanup) + console.log(`โณ Cleaning up operator resources in ${targetNamespace}...`); + const cleanupSuccess = await cleanupOperatorResources(k8sClient, { + operatorPackageName, + operandPlural, + testOperand, + namespace: targetNamespace, + }); + + if (!cleanupSuccess) { + console.log(`โŒ Namespace cleanup failed for ${targetNamespace}`); + } + + // Clean up InstallPlans (OpenShift doesn't remove these automatically) + console.log(`โณ Cleaning up InstallPlans for ${operatorPackageName}...`); + try { + const installPlans = await k8sClient.listCustomResources('operators.coreos.com', 'v1alpha1', targetNamespace, 'installplans'); + const operatorInstallPlans = installPlans.filter((ip: any) => { + const csvNames = ip.spec?.clusterServiceVersionNames || []; + return csvNames.some((csvName: string) => csvName.includes(operatorPackageName)); + }); + + for (const installPlan of operatorInstallPlans) { + try { + console.log(` Deleting InstallPlan: ${installPlan.metadata.name}`); + await k8sClient.deleteCustomResource('operators.coreos.com', 'v1alpha1', targetNamespace, 'installplans', installPlan.metadata.name); + console.log(` โœ… Deleted InstallPlan: ${installPlan.metadata.name}`); + } catch (error) { + console.log(` โš ๏ธ Could not delete InstallPlan ${installPlan.metadata.name}: ${error.message}`); + } + } + } catch (error) { + console.log(`Error cleaning up InstallPlans: ${error.message}`); + } + + // Clean up cluster Operator resource (OpenShift doesn't remove these automatically) + console.log(`โณ Cleaning up cluster Operator resource for ${operatorPackageName}...`); + try { + const operators = await k8sClient.listClusterCustomResources('operators.coreos.com', 'v1', 'operators'); + const matchingOperators = operators.filter((op: any) => + op.metadata.name?.includes(operatorPackageName) || + op.spec?.packageName === operatorPackageName + ); + + for (const operator of matchingOperators) { + try { + console.log(` Attempting to delete cluster Operator: ${operator.metadata.name}`); + + // First try to strip finalizers (operators often have finalizers that prevent deletion) + try { + console.log(` Stripping finalizers from ${operator.metadata.name}...`); + await k8sClient.patchClusterCustomResource( + 'operators.coreos.com', + 'v1', + 'operators', + operator.metadata.name, + [{ op: 'replace', path: '/metadata/finalizers', value: [] }] + ); + console.log(` โœ… Stripped finalizers from ${operator.metadata.name}`); + } catch (patchError) { + console.log(` โš ๏ธ Could not strip finalizers: ${patchError.message}`); + } + + // Now delete the operator + await k8sClient.deleteClusterCustomResource('operators.coreos.com', 'v1', 'operators', operator.metadata.name); + console.log(` โœ… Deleted cluster Operator: ${operator.metadata.name}`); + } catch (error) { + console.log(` โŒ Could not delete cluster Operator ${operator.metadata.name}: ${error.message}`); + } + } + + // Poll to verify all operators are actually deleted, but don't fail if they persist + console.log('โณ Verifying cluster Operator cleanup...'); + const pollInterval = 5000; // Check every 5 seconds + const maxWaitTime = 20000; // Max 20 seconds (shortened since CRD cleanup may resolve lingering operators) + const startTime = Date.now(); + + while (Date.now() - startTime < maxWaitTime) { + try { + const remainingOperators = await k8sClient.listClusterCustomResources('operators.coreos.com', 'v1', 'operators'); + const stillMatching = remainingOperators.filter((op: any) => + op.metadata.name?.includes(operatorPackageName) || + op.spec?.packageName === operatorPackageName + ); + + if (stillMatching.length === 0) { + console.log(`โœ… All ${operatorPackageName} cluster Operators successfully removed`); + break; + } else { + const elapsed = Math.round((Date.now() - startTime) / 1000); + console.log(`โณ Still ${stillMatching.length} operators remaining after ${elapsed}s: ${stillMatching.map((op: any) => op.metadata.name).join(', ')}`); + } + } catch (error) { + console.log(`Error checking operator cleanup status: ${error.message}`); + } + + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + + // Final check and warn if operators still exist (but continue with cleanup) + try { + const finalOperators = await k8sClient.listClusterCustomResources('operators.coreos.com', 'v1', 'operators'); + const stillPresent = finalOperators.filter((op: any) => + op.metadata.name?.includes(operatorPackageName) || + op.spec?.packageName === operatorPackageName + ); + + if (stillPresent.length > 0) { + console.log(`โš ๏ธ Warning: ${stillPresent.length} ${operatorPackageName} operators remain after cleanup. CRD deletion may resolve this.`); + } + } catch (error) { + console.log(`Could not perform final operator check: ${error.message}`); + } + } catch (error) { + console.log(`Error cleaning up cluster Operators: ${error.message}`); + } + + console.log(`โœ… Standard cleanup complete for ${targetNamespace}`); + + // Clean up specified CRDs if provided (for complete operator cleanup) + if (crdPatterns.length > 0) { + console.log(`๐Ÿงน Cleaning up CRDs matching patterns: ${crdPatterns.join(', ')}`); + try { + const allCRDs = await k8sClient.listClusterCustomResources('apiextensions.k8s.io', 'v1', 'customresourcedefinitions'); + + for (const pattern of crdPatterns) { + const matchingCRDs = allCRDs.filter((crd: any) => crd.metadata.name?.endsWith(pattern)); + + if (matchingCRDs.length > 0) { + console.log(`Found ${matchingCRDs.length} CRDs matching pattern '${pattern}'`); + for (const crd of matchingCRDs) { + try { + console.log(` Deleting CRD: ${crd.metadata.name}`); + await k8sClient.deleteClusterCustomResource('apiextensions.k8s.io', 'v1', 'customresourcedefinitions', crd.metadata.name); + console.log(` โœ… Deleted CRD: ${crd.metadata.name}`); + } catch (error) { + console.log(` โš ๏ธ Could not delete CRD ${crd.metadata.name}: ${error.message}`); + } + } + } else { + console.log(`No CRDs found matching pattern '${pattern}'`); + } + } + + // Poll for CRD deletions to complete + if (crdPatterns.some(pattern => allCRDs.some((crd: any) => crd.metadata.name?.endsWith(pattern)))) { + console.log('โณ Polling for CRD deletions to complete...'); + const pollInterval = 2000; // Check every 2 seconds + const maxWaitTime = 30000; // Max 30 seconds for CRD cleanup + const startTime = Date.now(); + + while (Date.now() - startTime < maxWaitTime) { + try { + const currentCRDs = await k8sClient.listClusterCustomResources('apiextensions.k8s.io', 'v1', 'customresourcedefinitions'); + const remainingCRDs = crdPatterns.flatMap(pattern => + currentCRDs.filter((crd: any) => crd.metadata.name?.endsWith(pattern)) + ); + + if (remainingCRDs.length === 0) { + const elapsedTime = Math.round((Date.now() - startTime) / 1000); + console.log(`โœ… All CRDs deleted successfully after ${elapsedTime}s`); + break; + } else { + const elapsedTime = Math.round((Date.now() - startTime) / 1000); + console.log(`โณ Still waiting for CRD cleanup (${elapsedTime}s): ${remainingCRDs.length} CRDs remaining`); + } + } catch (error) { + console.log(`Error checking CRD cleanup status: ${error.message}`); + } + + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + } + } catch (error) { + console.log(`โš ๏ธ Error during CRD cleanup: ${error.message}`); + } + } + + // Final verification: check if CRD deletion resolved any lingering operators + if (crdPatterns.length > 0) { + console.log('๐Ÿ” Final verification: checking if operators were resolved by CRD deletion...'); + try { + const finalOperators = await k8sClient.listClusterCustomResources('operators.coreos.com', 'v1', 'operators'); + const remainingOperators = finalOperators.filter((op: any) => + op.metadata.name?.includes(operatorPackageName) || + op.spec?.packageName === operatorPackageName + ); + + if (remainingOperators.length === 0) { + console.log(`โœ… All ${operatorPackageName} operators completely removed after CRD cleanup`); + } else { + console.log(`โš ๏ธ Warning: ${remainingOperators.length} ${operatorPackageName} operators still present: ${remainingOperators.map((op: any) => op.metadata.name).join(', ')}`); + console.log('๐Ÿ’ก This may be normal - some operators persist until next OLM sync cycle'); + } + } catch (error) { + console.log(`Could not perform final verification: ${error.message}`); + } + } + + return true; + + } catch (error) { + console.log(`โŒ Cleanup error: ${error.message}`); + return false; + } +} + +/** + * 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. Follow proper OpenShift operator cleanup procedure + 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(`Found subscription: ${subscription.metadata.name} in ${namespace}`); + + // Step 1: Get the currentCSV from the subscription + const currentCSV = subscription.status?.currentCSV; + if (currentCSV) { + console.log(` currentCSV: ${currentCSV}`); + } + + // Step 2: Delete the subscription + console.log(` Deleting subscription: ${subscription.metadata.name}`); + await k8sClient.deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + namespace, + 'subscriptions', + subscription.metadata.name, + ); + + // Step 3: Delete the CSV using the currentCSV value + if (currentCSV) { + try { + console.log(` Deleting ClusterServiceVersion: ${currentCSV}`); + await k8sClient.deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + namespace, + 'clusterserviceversions', + currentCSV, + ); + console.log(` โœ… Successfully deleted CSV ${currentCSV} in ${namespace}`); + } catch (csvError) { + console.log(` โš ๏ธ Could not delete CSV ${currentCSV}: ${csvError.message}`); + } + } else { + console.log(` โš ๏ธ No currentCSV found for 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}`); + } + } + + console.log(`โœ… cleanupOperatorResources FINISHED for ${namespace}`); + return true; + } catch (error) { + console.log(`Error cleaning up ${operatorPackageName} in ${namespace}:`, error.message); + return false; + } +} \ 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 a1c702cdfee..1ea357db661 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 2fe5268f79d..334ee0df273 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..d5ce88bb1de --- /dev/null +++ b/frontend/e2e/tests/olm/catalog-source-details.spec.ts @@ -0,0 +1,139 @@ +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 + const catalogSourceElement = page.getByTestId(catalogSourceName); + await expect(catalogSourceElement).toBeVisible({ timeout: 60_000 }); + + // Scroll into view if needed + await catalogSourceElement.scrollIntoViewIfNeeded(); + + 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 dde48221630..9b3284ce580 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-')); + + 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..8bc69e31d25 --- /dev/null +++ b/frontend/e2e/tests/olm/edit-default-sources.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from '../../fixtures'; +import { OperatorHubDetailsPage } from '../../pages/operator-hub-details-page'; + +test.describe('OperatorHub default sources management', { tag: ['@admin'] }, () => { + test.afterEach(async ({ k8sClient }) => { + // Ensure redhat-operators source is always enabled after test + try { + await k8sClient.patchClusterCustomResource( + 'config.openshift.io', + 'v1', + 'operatorhubs', + 'cluster', + [{ op: 'replace', path: '/spec/sources/0/disabled', value: false }] + ); + console.log('โœ… Ensured redhat-operators source is enabled'); + } catch (error) { + console.log('โš ๏ธ Could not ensure redhat-operators source is enabled:', error.message); + } + }); + + 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..3de94ad0935 --- /dev/null +++ b/frontend/e2e/tests/olm/operator-hub.spec.ts @@ -0,0 +1,105 @@ +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 = catalogPage.getFirstCatalogTileTitle(); + + // Validate that we captured a valid tile title + await expect(originalTileText).toHaveText(); + expect(originalTileText?.trim()).not.toBe(''); + + // 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()).toHaveCount(0, { timeout: 10_000 }); + + // Assert clear filters button is visible and click it + const clearButton = catalogPage.getClearFiltersButton(); + await expect(clearButton).toBeVisible(); + await catalogPage.clickClearAllFilters(); + + // Verify search input is empty and catalog tiles return + await expect(catalogPage.getSearchInput()).toBeEmpty(); + 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..7ebca14c4ee --- /dev/null +++ b/frontend/e2e/tests/olm/operator-install-global.spec.ts @@ -0,0 +1,116 @@ +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 { operatorTestCleanup } 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'; + + +test.describe(`Globally installing "${testOperator.name}" operator in ${globalNamespace}`, { tag: ['@admin'] }, () => { + test.beforeEach(async ({ k8sClient, page }) => { + console.log('\n๐Ÿงน ========= BEFORE EACH: Starting cleanup ========='); + + const cleanupSuccess = await operatorTestCleanup(k8sClient, { + operatorPackageName, + operandPlural: 'infinispans', + testOperand, + targetNamespace: globalNamespace, + crdPatterns: ['.infinispan.org'], // Clean up Data Grid CRDs + }); + + if (!cleanupSuccess) { + throw new Error('Cleanup failed to establish clean state'); + } + + console.log('๐ŸŽ‰ ========= BEFORE EACH: ALL CLEANUP COMPLETE =========\n'); + }); + + test.afterEach(async ({ k8sClient }) => { + console.log('\n๐Ÿงฝ ========= AFTER EACH: Verifying UI uninstall ========='); + + try { + await operatorTestCleanup(k8sClient, { + operatorPackageName, + operandPlural: 'infinispans', + testOperand, + targetNamespace: globalNamespace, + crdPatterns: ['.infinispan.org'], // Clean up Data Grid CRDs + waitForUiUninstall: true, // Give UI uninstall a moment to start + uiUninstallTimeoutMs: 10_000, // Brief delay before cleanup + }); + } catch (error) { + console.log(`โŒ Safety cleanup error: ${error.message}`); + // Don't throw here - we don't want afterEach to fail the test + } + + console.log('๐ŸŽ‰ ========= AFTER EACH: COMPLETE =========\n'); + }); + + test(`Globally installs ${testOperator.name} operator in ${globalNamespace} and creates ${testOperand.name} operand`, async ({ + page, + k8sClient, + cleanup, + }) => { + console.log('\n๐Ÿš€ ========= TEST EXECUTION STARTING =========\n'); + + 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..685acedd948 --- /dev/null +++ b/frontend/e2e/tests/olm/operator-install-single-namespace.spec.ts @@ -0,0 +1,182 @@ +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 { operatorTestCleanup, 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: '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'; + +// Track namespaces created by this test suite +const ownedNamespaces = new Set(); + +// Enhanced cleanup wrapper for Data Grid operator in single namespace tests +async function cleanupDataGridOperatorSingleNamespace(k8sClient: any) { + await cleanupOperatorResources(k8sClient, { + operatorPackageName, + operandPlural: 'infinispans', + 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); + + // Clean up only namespaces created by this test worker (from aborted runs) + try { + for (const nsName of ownedNamespaces) { + console.log(`Cleaning up owned test namespace: ${nsName}`); + await operatorTestCleanup(k8sClient, { + operatorPackageName, + operandPlural: 'infinispans', + testOperand, + targetNamespace: nsName, + crdPatterns: ['.infinispan.org'], // Clean up Data Grid CRDs + }); + } + } catch (error) { + console.log('Error cleaning up owned 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 ==='); + + try { + // Check each owned namespace for UI uninstall completion (but don't be as aggressive as beforeEach) + for (const nsName of ownedNamespaces) { + console.log(`๐Ÿ” Checking UI uninstall for namespace: ${nsName}`); + await operatorTestCleanup(k8sClient, { + operatorPackageName, + operandPlural: 'infinispans', + testOperand, + targetNamespace: nsName, + crdPatterns: ['.infinispan.org'], // Clean up Data Grid CRDs + waitForUiUninstall: true, + uiUninstallTimeoutMs: 10_000, // Brief delay before cleanup + }); + } + } catch (error) { + console.log(`โš ๏ธ Verification error: ${error.message}`); + } + + // Clear tracked namespaces for this test + ownedNamespaces.clear(); + console.log('=== SINGLE NAMESPACE AFTER EACH: 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(); + ownedNamespaces.add(testNamespace); + + await test.step('Install operator in new test namespace', async () => { + try { + cleanup.trackNamespace(testNamespace); + await installPage.installOperatorInNewNamespace( + testOperator.name, + testOperator.operatorCardTestID, + 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 this specific operator is not there + await installedOperatorsPage.verifyOperatorNotInstalledInNamespace(testOperator.name, globalNamespace); + }); + + 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 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(url => url.pathname.endsWith(`/${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); + }); + }); +}); 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..a3dafe4aeae --- /dev/null +++ b/frontend/e2e/tests/olm/operator-uninstall.spec.ts @@ -0,0 +1,169 @@ +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 { operatorTestCleanup } from '../../test-utils/operator-cleanup'; +import { performOperatorCleanup, 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: 'Infinispan', + group: 'infinispan.org', + version: 'v1', + kind: 'Infinispan', + createActionID: 'list-page-create-dropdown-item-infinispan.org~v1~Infinispan', + exampleName: 'example-infinispan', +}; + +// Test configuration for cleanup +const testConfig: OperatorTestConfig = { + operatorName: testOperator.name, + operatorCardTestID: testOperator.operatorCardTestID, + packageName: 'datagrid', + operand: { + ...testOperand, + plural: 'infinispans', + }, + globalNamespace: 'openshift-operators', +}; + + +test.describe('Testing uninstall of Data Grid Operator', { tag: ['@admin'] }, () => { + test.describe.configure({ timeout: 300_000 }); // 5 minutes - allow for operator install/uninstall operations + + test.beforeAll(async ({ k8sClient }) => { + console.log('=== UNINSTALL TEST: Starting initial cleanup ==='); + await performOperatorCleanup(k8sClient, testConfig); + console.log('=== UNINSTALL TEST: Initial cleanup complete ==='); + }); + + test.afterAll(async ({ k8sClient }) => { + console.log('=== UNINSTALL TEST: Starting final cleanup ==='); + + // Since this test does UI uninstall, give it a moment then clean up remaining resources + // (InstallPlans, Operator resources, CRDs that OpenShift doesn't auto-remove) + await operatorTestCleanup(k8sClient, { + operatorPackageName: testConfig.packageName, + operandPlural: testConfig.operand?.plural || 'infinispans', + testOperand: testConfig.operand, + targetNamespace: testConfig.globalNamespace || 'openshift-operators', + crdPatterns: ['.infinispan.org'], // Clean up Data Grid CRDs + waitForUiUninstall: true, + uiUninstallTimeoutMs: 10_000, // Brief delay before cleanup + }); + + console.log('โœ… Uninstall test cleanup complete'); + }); + + + 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 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 (with shorter timeout for faster feedback) + 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 (with operands)', async () => { + // Navigate back to operator details page to ensure clean state + await installedOperatorsPage.navigateToOperatorDetails(testOperator.name, testOperator.urlName, testNamespace); + + // Uninstall operator and delete the operand that was created + await operatorDetailsPage.uninstallOperatorWithOperands(true); + + // Verify operator no longer exists + await installedOperatorsPage.verifyOperatorNotExists(testOperator.name); + }); + + await test.step('Verify operand instance is deleted', async () => { + // Poll for operand deletion completion instead of fixed wait + console.log(`๐Ÿ” Polling for operand ${testOperand.exampleName} deletion...`); + + await expect(async () => { + try { + const result = await k8sClient.getCustomResource( + testOperand.group, + testOperand.version, + testNamespace, + 'infinispans', + testOperand.exampleName + ); + console.log(`โณ Operand still exists, continuing to poll...`); + throw new Error('Operand still exists'); + } catch (error) { + if (error.message?.includes('404') || error.message?.includes('not found')) { + console.log('โœ… Operand deletion confirmed'); + return; // Success - operand is deleted + } + throw error; // Re-throw other errors + } + }).toPass({ timeout: 120_000, intervals: [5_000] }); // Poll every 5 seconds for up to 2 minutes + }); + + }); +}); 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 b2a786e432b..00000000000 --- a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/deprecated-operator-warnings.cy.ts +++ /dev/null @@ -1,285 +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(function () { - cy.login(); - // cy.window() returns a Cypress Chainable, not a true Promise โ€” it has no .catch() method. - // Cypress's command queue manages error handling; this disable is required. - // eslint-disable-next-line promise/catch-or-return - cy.window().then((win) => { - if (win.SERVER_FLAGS?.techPreview) { - this.skip(); - } - }); - // 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/modals/edit-default-sources-modal.tsx b/frontend/packages/operator-lifecycle-manager/src/components/modals/edit-default-sources-modal.tsx index 75bd1fca429..1ab83521cdd 100644 --- a/frontend/packages/operator-lifecycle-manager/src/components/modals/edit-default-sources-modal.tsx +++ b/frontend/packages/operator-lifecycle-manager/src/components/modals/edit-default-sources-modal.tsx @@ -71,6 +71,7 @@ const EditDefaultSourcesModal: FC = ({ <> @@ -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 3185206ce72..15a3e68d443 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 @@ -397,6 +397,7 @@ export const UninstallOperatorModal: FC = ({ @@ -447,7 +448,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 39b0f72b61e..c18726aec16 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, })); }; @@ -132,6 +133,7 @@ export const RegistryPollIntervalDetailItem: FC