diff --git a/frontend/e2e/clients/kubernetes-client.ts b/frontend/e2e/clients/kubernetes-client.ts index 789f87b29d3..5cd2cd64010 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,47 +577,43 @@ export default class KubernetesClient { } } - async getCustomResource( + async patchClusterCustomResource( group: string, version: string, - namespace: string, plural: string, name: string, - ): Promise { - const response = await this.coApi.getNamespacedCustomObject({ - group, - name, - namespace, - plural, - version, - }); - return response; - } + patch: object | object[], + ): Promise { + if (Array.isArray(patch)) { + await this.coApi.patchClusterCustomObject({ + group, + name, + plural, + version, + body: patch, + contentType: k8s.PatchStrategy.JsonPatch, + } as any); + return; + } - async getClusterCustomResource( - group: string, - version: string, - plural: string, - name: string, - ): Promise { - return this.coApi.getClusterCustomObject({ group, name, plural, version }); + await this.mergePatchResource(`/apis/${group}/${version}/${plural}/${name}`, patch); } - async patchClusterCustomResource( + async getCustomResource( group: string, version: string, + namespace: string, plural: string, name: string, - patch: object, ): Promise { - return this.coApi.patchClusterCustomObject({ - body: patch, + const response = await this.coApi.getNamespacedCustomObject({ group, name, + namespace, plural, version, - contentType: k8s.PatchStrategy.MergePatch, - } as any); + }); + return response; } async createPVC(namespace: string, body: k8s.V1PersistentVolumeClaim): Promise { @@ -653,6 +657,26 @@ export default class KubernetesClient { } as any); } + 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, @@ -672,6 +696,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/base-page.ts b/frontend/e2e/pages/base-page.ts index 83467a30f24..b94063ab616 100644 --- a/frontend/e2e/pages/base-page.ts +++ b/frontend/e2e/pages/base-page.ts @@ -26,11 +26,15 @@ export async function setEditorContent(page: Page, content: string): Promise { +export async function gotoAuthenticated(page: Page, url: string): Promise { await expect(async () => { - await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 }); await expect(page.locator('#page-sidebar')).toBeVisible({ timeout: 30_000 }); }).toPass({ intervals: [1_000, 2_000, 5_000], timeout: 90_000 }); +} + +export async function warmupSPA(page: Page): Promise { + await gotoAuthenticated(page, '/'); await dismissQuickStartDrawer(page); } @@ -112,7 +116,7 @@ export default abstract class BasePage { } protected async goTo(url: string): Promise { - await this.page.goto(url, { timeout: 90_000 }); + await gotoAuthenticated(this.page, url); await this.waitForLoadingComplete(); } diff --git a/frontend/e2e/pages/catalog-page.ts b/frontend/e2e/pages/catalog-page.ts index 80d95297ea7..84f9900d38d 100644 --- a/frontend/e2e/pages/catalog-page.ts +++ b/frontend/e2e/pages/catalog-page.ts @@ -7,6 +7,9 @@ import BasePage from './base-page'; export class CatalogPage extends BasePage { private readonly pageHeading: Locator = this.page.getByTestId('page-heading'); 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'); async navigateToCatalog(namespace?: string): Promise { const url = namespace ? `/catalog/ns/${namespace}` : '/catalog/all-namespaces'; @@ -23,10 +26,110 @@ export class CatalogPage extends BasePage { await expect(this.pageHeading).toBeVisible({ timeout: 60_000 }); } + async navigateToSoftwareCatalog(namespace: string): Promise { + await this.goTo(`/catalog/ns/${namespace}`); + await expect(this.pageHeading).toBeVisible({ timeout: 60_000 }); + } + + async navigateToOperatorCatalog(namespace: string): Promise { + await this.goTo(`/catalog/ns/${namespace}?catalogType=operator`); + await expect(this.pageHeading).toBeVisible({ timeout: 60_000 }); + } + + async navigateToPath(url: string): Promise { + await this.goTo(url); + } + 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 this.robustClick(filterCheckbox); + } + + async toggleSourceFilterByLabel(label: string): Promise { + await this.robustClick(this.page.getByRole('checkbox', { name: label }), { + timeout: 60_000, + }); + } + + getOperatorCard(operatorName: string): Locator { + return this.page + .getByTestId(`operator-${operatorName}`) + .filter({ hasNotText: 'testing deprecation' }); + } + + async clickOperatorCard(operatorName: string): Promise { + await this.robustClick(this.getOperatorCard(operatorName), { timeout: 60_000 }); + } + + getDeprecatedWarningBadge(): Locator { + return this.page.getByTestId('deprecated-operator-warning-badge'); + } + + // Software Catalog tiles and the catalog details drawer render deprecation via the generic + // CatalogBadges component, which derives data-test from the badge text (`${text}-badge`) — + // not the OLM-specific `deprecated-operator-warning-badge` used on CSV/installed-operator pages. + // Scoped to the details dialog since the background tile grid renders the same badge testid. + getCatalogDeprecatedBadge(): Locator { + return this.page.getByRole('dialog').getByTestId('Deprecated-badge'); + } + + getDeprecatedWarning(testId: string): Locator { + return this.page.getByTestId(testId); + } + + async clickCategoryFilter(categoryId: string): Promise { + const categoryTab = this.page.getByTestId(`tab ${categoryId}`); + 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'); + } + + async getFirstCatalogTileTitleText(): Promise { + return this.getFirstCatalogTileTitle().innerText(); + } + + getClearFiltersButton(): Locator { + return this.clearFiltersButton; + } + + getPageHeading(): Locator { + return this.pageHeading; + } + + getSearchInput(): Locator { + return this.searchCatalogInput; + } + catalogItem(testId: string): Locator { return this.page.getByTestId(testId); } @@ -83,10 +186,6 @@ export class CatalogPage extends BasePage { await this.robustClick(this.page.getByRole('link', { name: /create application/i })); } - getPageHeading(): Locator { - return this.pageHeading; - } - getFilterInput(): Locator { return this.filterInput; } @@ -95,11 +194,6 @@ export class CatalogPage extends BasePage { return this.page.getByText(text); } - getCatalogTiles(): Locator { - // co-catalog-tile: Console's catalog tile class from CatalogTile.tsx - return this.page.locator('.co-catalog-tile'); - } - getFormSubmitButton(): Locator { return this.page.getByRole('button', { name: 'Create', exact: true }); } @@ -107,4 +201,12 @@ export class CatalogPage extends BasePage { getProjectSelectionMessage(): Locator { return this.page.getByText('Select a Project to view the software catalog'); } + + async verifyTileContainsText(expectedText: string): Promise { + await expect(this.getFirstCatalogTileTitle()).toContainText(expectedText); + } + + 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..81510aa6c31 --- /dev/null +++ b/frontend/e2e/pages/catalog-source-page.ts @@ -0,0 +1,81 @@ +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); + } + + // The OperatorHub "Sources" tab list is scoped to the currently active project, which is + // unreliable for a CatalogSource created in a one-off test namespace. Navigate to its details + // page directly instead of locating it via that tab. + async navigateToDetails(namespace: string, name: string): Promise { + await this.goTo( + `/k8s/ns/${namespace}/operators.coreos.com~v1alpha1~CatalogSource/${name}`, + ); + } + + 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/cluster-settings-page.ts b/frontend/e2e/pages/cluster-settings-page.ts index 59afb0521bc..713b7309511 100644 --- a/frontend/e2e/pages/cluster-settings-page.ts +++ b/frontend/e2e/pages/cluster-settings-page.ts @@ -30,7 +30,7 @@ export class ClusterSettingsPage extends BasePage { */ async navigateToDetails(): Promise { await this.goTo('/settings/cluster'); - await expect(this.detailsTab).toBeVisible(); + await expect(this.detailsTab).toBeVisible({ timeout: 60_000 }); } /** 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..c681356b26a 100644 --- a/frontend/e2e/pages/installed-operators-page.ts +++ b/frontend/e2e/pages/installed-operators-page.ts @@ -1,8 +1,19 @@ import type { Locator } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import { escapeRegExp } from '../utils/selector-utils'; 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'); + + /** + * 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 +21,205 @@ 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.locator('tr').filter({ has: this.page.getByTestId(`operator-row-${operatorName}`) }); + } + + /** + * Get operator status element + */ + getOperatorStatus(operatorName: string): Locator { + return this.getOperatorRow(operatorName).getByTestId('status-text'); + } + + /** + * Click on operator row to navigate to details + */ + async clickOperatorRow(operatorName: string): Promise { + // Get h1 child of the operator row (clicking the directly is flaky, hitting the

works) + const operatorLink = this.getOperatorRow(operatorName).locator('h1'); + + await expect(operatorLink).toBeVisible({ timeout: 30_000 }); + await this.robustClick(operatorLink); + } + + /** + * Verify operator installation succeeded + */ + async verifyOperatorInstallationSucceeded(operatorName: string): Promise { + await this.navigateToInstalledOperators(); + await this.filterByName(operatorName); + + const operatorRow = this.getOperatorRow(operatorName); + await expect(operatorRow).toBeVisible({ timeout: 60_000 }); + + const statusElement = this.getOperatorStatus(operatorName); + await expect(async () => { + const currentText = await statusElement.textContent({ timeout: 5_000 }); + expect(currentText ?? '').not.toContain('Failed'); + expect(currentText ?? '').toContain('Succeeded'); + }).toPass({ intervals: [5_000], timeout: 180_000 }); + } + + /** + * Navigate to operator details page + */ + async navigateToOperatorDetails(operatorName: string, operatorURLName: string, namespace: string = 'openshift-operators'): Promise { + await this.navigateToInstalledOperators(); + + // Select namespace before filtering for the operator row. + await this.selectNamespace(namespace); + + await this.filterByName(operatorName); + + // Wait for the operator row to be visible + await expect(this.getOperatorRow(operatorName)).toBeVisible({ timeout: 30_000 }); + + // Navigate via href to bypass unreliable h1-click-inside-logo-link PF v6 behavior. + const href = await this.page.getByTestId(`operator-row-${operatorName}`).getAttribute('href'); + await this.goTo(href ?? `/k8s/ns/${namespace}/operators.coreos.com~v1~ClusterServiceVersion/${operatorURLName}`); + + await expect(this.page.getByTestId('resource-summary')).toBeVisible({ timeout: 60_000 }); + await expect(this.page.getByTestId('horizontal-link-Details')).toBeVisible({ timeout: 60_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 the expected empty state. + const emptyState = this.page.getByTestId('console-empty-state'); + await expect(this.nameFilterInput.or(emptyState).first()).toBeVisible({ timeout: 10_000 }); + + if (await emptyState.isVisible()) { + await expect(emptyState).toContainText('No Operators found', { timeout: 10_000 }); + return; + } + + await this.filterByName(operatorName); + + // 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').getByRole('button').first(); + 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 escapedNamespace = escapeRegExp(namespace); + const namespaceOption = this.page + .getByTestId('dropdown-menu-item-link') + .filter({ hasText: new RegExp(`^${escapedNamespace}$`) }); + await this.robustClick(namespaceOption); + + const normalizedNamespace = escapedNamespace.replace(/\s+/g, '\\s+'); + await expect(namespaceDropdownButton).toHaveText( + new RegExp(`^(?:Project|Namespace):\\s*${normalizedNamespace}\\s*$`), + ); + } + + 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..cb06bcb32eb --- /dev/null +++ b/frontend/e2e/pages/operand-page.ts @@ -0,0 +1,84 @@ +import type { Locator } from '@playwright/test'; + +import { quoteAttributeValue } from '../utils/selector-utils'; + +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)); + } + + 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, { timeout: 60_000 }); + } + + getFormHeading(): Locator { + return this.page.getByTestId('page-heading').locator('h1'); + } + + getFormFieldElement(id: string): Locator { + return this.page.locator(`[id="${quoteAttributeValue(`${id}_field`)}"]`); + } + + getFormFieldLabel(id: string): Locator { + return this.page.locator(`[for="${id}"]`); + } + + getFormFieldInput(id: string): Locator { + return this.page.locator(`[id="${quoteAttributeValue(id)}"]`); + } + + getFormFieldGroup(id: string): Locator { + return this.page.locator(`[id="${quoteAttributeValue(`${id}_field-group`)}"]`); + } + + getFormFieldGroupToggle(id: string): Locator { + return this.page.locator(`[id="${quoteAttributeValue(`${id}_accordion-toggle`)}"]`); + } + + async toggleFieldGroup(id: string): Promise { + await this.robustClick(this.getFormFieldGroupToggle(id)); + } + + getTagItemContent(fieldId: string): Locator { + return this.page.locator( + `[id="${quoteAttributeValue(`${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..f6133aacb89 --- /dev/null +++ b/frontend/e2e/pages/operator-details-page.ts @@ -0,0 +1,335 @@ +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**', + async (route) => { + await 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/**', async (route) => { + if (route.request().method() === 'DELETE') { + await 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 { + await 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') { + const deleteAllOperands = this.page.getByTestId('delete-all-operands'); + await deleteAllOperands.click(); + await expect(deleteAllOperands).toBeChecked(); + await this.modalPage.submit(); + + // 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.detailsPage.getSectionHeader(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..b513f61f848 --- /dev/null +++ b/frontend/e2e/pages/operator-hub-details-page.ts @@ -0,0 +1,108 @@ +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.robustClick(this.page.getByTestId('OperatorHub')); + 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 this.robustClick(checkbox); + } + + /** + * 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.getByTestId(`section-heading-${heading}`); + await expect(sectionHeading).toBeVisible({ timeout: 30_000 }); + } + + /** + * Complete flow: toggle source, verify status, and toggle back + */ + async toggleSourceAndVerify( + sourceName: string, + statusAfterToggle: string, + statusAfterRevert: 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(statusAfterToggle, { + timeout: 60_000, + }); + + // 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(statusAfterRevert, { + timeout: 60_000, + }); + } +} \ 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..a3914327510 --- /dev/null +++ b/frontend/e2e/pages/operator-install-page.ts @@ -0,0 +1,210 @@ +import type { Locator } from '@playwright/test'; + +import { expect } from '../fixtures'; +import { escapeRegExp } from '../utils/selector-utils'; + +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'); + private readonly createNamespaceOption = this.page.getByRole('option', { + name: /^Create (Project|Namespace)$/, + }); + private readonly namespaceNameInput = this.page.getByTestId('input-name'); + private readonly confirmAction = this.page.getByTestId('confirm-action'); + private readonly detailsModal = this.page.getByRole('dialog'); + + private async openInstallForm(operatorName: string, operatorCardTestID: string): Promise { + await this.goTo('/catalog/all-namespaces?catalogType=operator'); + await expect(this.catalogPage.getPageHeading()).toBeVisible({ timeout: 60_000 }); + await this.catalogPage.searchOperators(operatorName); + + const operatorCard = this.page + .getByTestId(operatorCardTestID) + .filter({ hasNotText: 'testing deprecation' }); + await this.robustClick(operatorCard, { timeout: 30_000 }); + + await expect(this.installButton).toHaveAttribute('href'); + await this.robustClick(this.installButton); + } + + /** + * Install an operator globally in openshift-operators + */ + async installOperatorGlobally(operatorName: string, operatorCardTestID: string): Promise { + await this.openInstallForm(operatorName, operatorCardTestID); + + // 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.openInstallForm(operatorName, operatorCardTestID); + + // 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 expect(this.selectNamespaceRadio).toBeVisible({ timeout: 5_000 }); + await this.selectNamespaceRadio.check(); + } catch { + // Element not available within timeout, continue without checking it + } + + // Select the namespace + await this.robustClick(this.namespaceDropdown); + await this.searchInput.fill(namespace); + const escapedNamespace = escapeRegExp(namespace); + const namespaceOption = this.page + .getByTestId('dropdown-menu-item-link') + .filter({ hasText: new RegExp(`^${escapedNamespace}$`) }) + .first(); + await this.robustClick(namespaceOption); + 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.openInstallForm(operatorName, operatorCardTestID); + + // Configure for specific namespace installation + await this.specificNamespaceRadio.check({ timeout: 30_000 }); + + // Check if select namespace radio exists with timeout + try { + await expect(this.selectNamespaceRadio).toBeVisible({ timeout: 5_000 }); + await this.selectNamespaceRadio.check(); + } catch { + // Element not available within timeout, continue without checking it + } + + await this.createNamespaceFromDropdown(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); + } + + async clickDetailsModalInstall(): Promise { + await expect(this.detailsModal).toBeVisible(); + await this.robustClick(this.detailsModal.getByRole('button', { name: 'Install' })); + } + + async selectSpecificNamespaceMode(): Promise { + await expect(this.page.getByRole('heading', { name: 'Install Operator' })).toBeVisible(); + await this.specificNamespaceRadio.check({ timeout: 30_000 }); + } + + async createNamespaceFromDropdown(namespace: string): Promise { + await this.robustClick(this.namespaceDropdown); + await this.robustClick(this.createNamespaceOption); + await this.namespaceNameInput.fill(namespace); + await this.robustClick(this.confirmAction); + await expect(this.detailsModal).toBeHidden(); + await expect(this.namespaceDropdown).toContainText(namespace); + } + + async clickInstallOperator(): Promise { + await this.robustClick(this.installOperatorButton); + } + + getNamespaceDropdown(): Locator { + return this.namespaceDropdown; + } + + getDeprecatedWarningIcon(kind: 'channel' | 'version'): Locator { + return this.page.getByTestId(`deprecated-operator-warning-${kind}-icon`); + } + + getChannelOption(channel: string): Locator { + return this.page.getByTestId(`channel-option-${channel}`); + } + + getVersionOption(version: string): Locator { + return this.page.getByTestId(`version-option-${version}`); + } + + getChannelSelect(): Locator { + return this.channelSelect; + } + + getVersionSelect(): Locator { + return this.versionSelect; + } + + async openChannelSelect(): Promise { + await this.robustClick(this.channelSelect); + } + + async openVersionSelect(): Promise { + await this.robustClick(this.versionSelect); + } + + async selectChannelOption(channel: string): Promise { + await this.robustClick(this.getChannelOption(channel)); + } + + async selectVersionOption(version: string): Promise { + await this.robustClick(this.getVersionOption(version)); + } + + 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/test-namespace.ts b/frontend/e2e/test-utils/test-namespace.ts new file mode 100644 index 00000000000..10dc19f3f3d --- /dev/null +++ b/frontend/e2e/test-utils/test-namespace.ts @@ -0,0 +1,7 @@ +/** + * Generate a unique test namespace name based on the current timestamp. + * Format: test-{base36 timestamp} — unique across runs, no collision risk. + */ +export function generateTestNamespace(): string { + return `test-${Date.now().toString(36)}`; +} \ 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..ee96a273583 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,10 @@ test.describe('Add storage for workloads', { tag: ['@admin'] }, () => { test.beforeAll(async ({ k8sClient }) => { namespace = `test-storage-${Date.now()}`; await k8sClient.createNamespace(namespace); + const namespaceReady = await k8sClient.waitForNamespaceReady(namespace); + if (!namespaceReady) { + throw new Error(`Namespace ${namespace} did not become ready in time`); + } }); 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 308ab86e1b3..d70a5dcb308 100644 --- a/frontend/e2e/tests/console/crud/other-routes.spec.ts +++ b/frontend/e2e/tests/console/crud/other-routes.spec.ts @@ -139,7 +139,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((url) => url.pathname === expectedPath); 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..980fc5a2cdc 100644 --- a/frontend/e2e/tests/console/crud/quotas.spec.ts +++ b/frontend/e2e/tests/console/crud/quotas.spec.ts @@ -23,13 +23,23 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { }); test.afterAll(async ({ k8sClient }) => { - await k8sClient.deleteClusterCustomResource( - 'quota.openshift.io', - 'v1', - 'clusterresourcequotas', - clusterQuotaName, - ); - await k8sClient.deleteNamespace(namespace); + const [clusterQuotaResult, namespaceResult] = await Promise.allSettled([ + k8sClient.deleteClusterCustomResource( + 'quota.openshift.io', + 'v1', + 'clusterresourcequotas', + clusterQuotaName, + ), + k8sClient.deleteNamespace(namespace), + ]); + + if (namespaceResult.status === 'rejected') { + throw namespaceResult.reason; + } + + if (clusterQuotaResult.status === 'rejected') { + throw clusterQuotaResult.reason; + } }); test('create ResourceQuota and ClusterResourceQuota via YAML editor', async ({ 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..b6cae0ef464 --- /dev/null +++ b/frontend/e2e/tests/olm/catalog-source-details.spec.ts @@ -0,0 +1,140 @@ +import { test, expect } from '../../fixtures'; +import { CatalogSourcePage } from '../../pages/catalog-source-page'; + +const managedCatalogSource = { + name: 'redhat-operators', + displayName: 'Red Hat Operators', + namespace: 'openshift-marketplace', +}; + +test.describe('CatalogSource details page', { tag: ['@admin'] }, () => { + test('renders details about a managed catalog source', async ({ page }) => { + test.setTimeout(360_000); + const catalogSourcePage = new CatalogSourcePage(page); + + await test.step('Navigate to CatalogSource details', async () => { + await catalogSourcePage.navigateToDetails(managedCatalogSource.namespace, managedCatalogSource.name); + await expect(catalogSourcePage.getSectionHeading('CatalogSource details')).toBeVisible({ + timeout: 60_000, + }); + }); + + 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.navigateToDetails(managedCatalogSource.namespace, managedCatalogSource.name); + await expect(catalogSourcePage.getSectionHeading('CatalogSource details')).toBeVisible({ + timeout: 60_000, + }); + }); + + 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 suffix = Date.now(); + const testNs = `test-catsrc-${suffix}`; + const catalogSourceName = `test-catsrc-${suffix}`; + 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.navigateToDetails(testNs, catalogSourceName); + await expect(catalogSourcePage.getSectionHeading('CatalogSource details')).toBeVisible({ + timeout: 60_000, + }); + }); + + await test.step('Edit registry poll interval to 30m', async () => { + await expect(catalogSourcePage.getDetailsValue('Registry poll interval')).toBeVisible({ + timeout: 30_000, + }); + 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', { + timeout: 60_000, + }); + }); + }); +}); diff --git a/frontend/e2e/tests/olm/create-namespace.spec.ts b/frontend/e2e/tests/olm/create-namespace.spec.ts index dde48221630..72dc30436af 100644 --- a/frontend/e2e/tests/olm/create-namespace.spec.ts +++ b/frontend/e2e/tests/olm/create-namespace.spec.ts @@ -1,108 +1,65 @@ import { test, expect } from '../../fixtures'; -import KubernetesClient from '../../clients/kubernetes-client'; +import { CatalogPage } from '../../pages/catalog-page'; +import { OperatorInstallPage } from '../../pages/operator-install-page'; +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('creates namespace from operator install page', async ({ page, cleanup }) => { + test.setTimeout(180_000); - test.beforeEach(async ({ k8sClient: client }) => { - k8sClient = client; - nsName = `test-create-ns-${Date.now()}`; - }); + const catalogPage = new CatalogPage(page); + const installPage = new OperatorInstallPage(page); - test.afterEach(async () => { - try { - await k8sClient.deleteCustomResource( - 'operators.coreos.com', - 'v1alpha1', - nsName, - 'subscriptions', - '3scale-community-operator', - ); - } catch { - // Ignore if not created - } - 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, - ); - } - } - } catch { - // Ignore cleanup errors - } - try { - await k8sClient.deleteNamespace(nsName); - } catch { - // Ignore if not created - } - }); - - test('creates namespace from operator install page', async ({ page }) => { + await catalogPage.navigateToOperatorCatalog('default'); // OLMv1 is enabled by default on techPreview clusters, replacing the OLMv0 // OperatorHub catalog with an empty Software Catalog. Skip instead of timing out. - await page.goto('/'); - const isTechPreview = await page.evaluate(() => window.SERVER_FLAGS.techPreview); - test.skip(isTechPreview, 'OLMv1 is active on techPreview clusters — OLMv0 OperatorHub catalog is unavailable'); + const isTechPreview = await page.evaluate(() => Boolean(window.SERVER_FLAGS?.techPreview)); + test.skip( + isTechPreview, + 'OLMv1 is active on techPreview clusters — OLMv0 OperatorHub catalog is unavailable', + ); + + const nsName = generateTestNamespace(); + cleanup.trackNamespace(nsName); + cleanup.trackCustomResource( + operatorPackageName, + nsName, + 'operators.coreos.com', + 'v1alpha1', + 'subscriptions', + ); await test.step('Navigate to catalog and open operator details', async () => { - await page.goto('/catalog/ns/default?catalogType=operator'); - await page.getByPlaceholder('Filter by keyword...').fill(operatorName); - await page.getByTestId(`operator-${operatorName}`).click(); + await catalogPage.toggleSourceFilter('community'); + await catalogPage.searchOperators(operatorName); + await catalogPage.clickOperatorCard(operatorName); }); await test.step('Click Install in operator details modal', async () => { - const dialog = page.getByRole('dialog'); - await expect(dialog).toBeVisible(); - - const installLink = dialog.getByRole('button', { name: 'Install' }); - await expect(installLink).toBeVisible(); - await installLink.click(); + await installPage.clickDetailsModalInstall(); }); await test.step('Select single namespace installation mode', async () => { - await expect(page.getByRole('heading', { name: 'Install Operator' })).toBeVisible(); - const radio = page.getByTestId('A specific namespace on the cluster-radio-input'); - await expect(radio).toBeVisible(); - await radio.click(); + await installPage.selectSpecificNamespaceMode(); }); await test.step('Create a new namespace from the dropdown', async () => { - await page.getByTestId('dropdown-selectbox').click(); - await page.locator('[data-test-dropdown-menu^="Create_"]').click(); - - await expect(page.getByTestId('input-name')).toBeVisible(); - await page.getByTestId('input-name').fill(nsName); - await page.getByTestId('confirm-action').click(); - - await expect(page.getByRole('dialog')).toBeHidden(); + await installPage.createNamespaceFromDropdown(nsName); }); await test.step('Verify the dropdown shows the new namespace', async () => { - await expect(page.getByTestId('dropdown-selectbox')).toContainText(nsName); + await expect(installPage.getNamespaceDropdown()).toContainText(nsName); }); await test.step('Install the operator and verify success', async () => { - await page.getByTestId('install-operator').click(); - - const successButton = page.getByTestId('view-installed-operators-btn'); - await expect(successButton).toContainText(`View installed Operators in Namespace ${nsName}`, { - timeout: 60000, - }); + await installPage.clickInstallOperator(); + await expect(installPage.getViewInstalledOperatorsButton()).toContainText( + `View installed Operators in Namespace ${nsName}`, + { timeout: 60_000 }, + ); }); }); }); diff --git a/frontend/e2e/tests/olm/deprecated-operator-warnings.spec.ts b/frontend/e2e/tests/olm/deprecated-operator-warnings.spec.ts new file mode 100644 index 00000000000..b00177706ff --- /dev/null +++ b/frontend/e2e/tests/olm/deprecated-operator-warnings.spec.ts @@ -0,0 +1,503 @@ +import * as path from 'path'; + +import type { Browser } from '@playwright/test'; + +import { test, expect } from '../../fixtures'; +import { gotoAuthenticated } from '../../pages/base-page'; +import { CatalogPage } from '../../pages/catalog-page'; +import { DetailsPage } from '../../pages/details-page'; +import { InstalledOperatorsPage } from '../../pages/installed-operators-page'; +import { OperatorInstallPage } from '../../pages/operator-install-page'; +import { generateTestNamespace } from '../../test-utils/test-namespace'; + +const BASE_URL = process.env.WEB_CONSOLE_URL || 'http://localhost:9000'; +const ADMIN_STORAGE_STATE = path.resolve(import.meta.dirname, '..', '..', '.auth', 'kubeadmin.json'); +const CATALOG_SOURCE_NAMESPACE = 'openshift-marketplace'; +const OPERATOR_DETAILS_NAMESPACE = 'default'; +const INSTALLED_OPERATOR_NAME = 'Kiali Operator'; +const DEPRECATED_BADGE = 'Deprecated'; +const DEPRECATED_PACKAGE_MESSAGE = 'package kiali is end of life'; +const DEPRECATED_CHANNEL_MESSAGE = 'channel alpha is no longer supported'; +const DEPRECATED_VERSION = 'kiali-operator.v1.68.0'; +const DEPRECATED_VERSION_MESSAGE = `${DEPRECATED_VERSION} is deprecated`; +const LATEST_VERSION = '1.83.0'; +const LATEST_VERSION_OPTION = 'kiali-operator.v1.83.0'; +const TECH_PREVIEW_SKIP_REASON = + 'OLMv1 is active on techPreview clusters — OLMv0 OperatorHub catalog is unavailable'; +const SETUP_TIMEOUT = 360_000; +const DEFAULT_DEPRECATED_OPERATOR_CATALOG_IMAGE = + 'quay.io/cajieh0/deprecation-catalog@sha256:0d49292bd51c36644aa703f18f777780af6bffd8748aa8f594111bde9639bcaa'; +const DEPRECATED_OPERATOR_CATALOG_IMAGE = + process.env.DEPRECATED_OPERATOR_CATALOG_IMAGE ?? DEFAULT_DEPRECATED_OPERATOR_CATALOG_IMAGE; +const CATALOG_SOURCE_DISPLAY_NAME = 'Community Operators for testing deprecation'; + +const runId = generateTestNamespace().replace('test-', ''); +const catalogSourceName = `test-community-operator-deprecation-${runId}`; +const subscriptionName = `kiali-${runId}`; +const subscriptionNamespace = generateTestNamespace(); +const selectedOperatorId = `kiali-${catalogSourceName}-${CATALOG_SOURCE_NAMESPACE}`; + +let isTechPreview = false; +let installedCsvName = DEPRECATED_VERSION; + +function buildDeprecatedCatalogSource() { + return { + apiVersion: 'operators.coreos.com/v1alpha1', + kind: 'CatalogSource', + metadata: { + name: catalogSourceName, + namespace: CATALOG_SOURCE_NAMESPACE, + }, + spec: { + displayName: CATALOG_SOURCE_DISPLAY_NAME, + image: DEPRECATED_OPERATOR_CATALOG_IMAGE, + publisher: 'OLM community', + sourceType: 'grpc', + updateStrategy: { + registryPoll: { + interval: '10m', + }, + }, + }, + }; +} + +function buildDeprecatedSubscription() { + return { + apiVersion: 'operators.coreos.com/v1alpha1', + kind: 'Subscription', + metadata: { + name: subscriptionName, + namespace: subscriptionNamespace, + }, + spec: { + source: catalogSourceName, + sourceNamespace: CATALOG_SOURCE_NAMESPACE, + name: 'kiali', + startingCSV: DEPRECATED_VERSION, + channel: 'alpha', + installPlanApproval: 'Manual', + }, + }; +} + +function getOperatorDetailsUrl(channel = 'stable', version = LATEST_VERSION): string { + return `/catalog/ns/${OPERATOR_DETAILS_NAMESPACE}?catalogType=operator&keyword=kia&selectedId=${selectedOperatorId}&channel=${channel}&version=${version}`; +} + +function getInstallPageUrl(): string { + return `/operatorhub/subscribe?pkg=kiali&catalog=${catalogSourceName}&catalogNamespace=${CATALOG_SOURCE_NAMESPACE}&targetNamespace=undefined&channel=alpha&version=1.68.0`; +} + +async function detectTechPreview(browser: Browser): Promise { + const context = await browser.newContext({ + ignoreHTTPSErrors: true, + storageState: ADMIN_STORAGE_STATE, + }); + + try { + const page = await context.newPage(); + await gotoAuthenticated(page, BASE_URL); + return await page.evaluate(() => Boolean(window.SERVER_FLAGS?.techPreview)); + } finally { + await context.close(); + } +} + +async function expectDeprecatedWarning( + catalogPage: CatalogPage, + testId: string, + text: string, +): Promise { + await expect(catalogPage.getDeprecatedWarning(testId)).toContainText(text, { timeout: 60_000 }); +} + +test.describe('Deprecated operator warnings', { tag: ['@admin'] }, () => { + test.describe.configure({ timeout: SETUP_TIMEOUT }); + + test.beforeAll(async ({ browser, k8sClient }) => { + test.setTimeout(SETUP_TIMEOUT); + + isTechPreview = await detectTechPreview(browser); + if (isTechPreview) { + return; + } + + await k8sClient.createCustomResource( + 'operators.coreos.com', + 'v1alpha1', + CATALOG_SOURCE_NAMESPACE, + 'catalogsources', + buildDeprecatedCatalogSource(), + ); + + await expect(async () => { + const catalogSource = (await k8sClient.getCustomResource( + 'operators.coreos.com', + 'v1alpha1', + CATALOG_SOURCE_NAMESPACE, + 'catalogsources', + catalogSourceName, + )) as { status?: { connectionState?: { lastObservedState?: string } } }; + + expect(catalogSource.status?.connectionState?.lastObservedState).toBe('READY'); + }).toPass({ timeout: 300_000, intervals: [5_000] }); + + await expect(async () => { + const manifests = (await k8sClient.listCustomResources( + 'packages.operators.coreos.com', + 'v1', + OPERATOR_DETAILS_NAMESPACE, + 'packagemanifests', + )) as Array<{ status?: { catalogSource?: string } }>; + expect(manifests.some((manifest) => manifest.status?.catalogSource === catalogSourceName)).toBe( + true, + ); + }).toPass({ timeout: 180_000, intervals: [5_000] }); + }); + + test.afterAll(async ({ k8sClient }) => { + if (isTechPreview) { + return; + } + + try { + const [subscriptionCleanup, namespaceCleanup] = await Promise.allSettled([ + k8sClient.deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'subscriptions', + subscriptionName, + ), + k8sClient.deleteNamespace(subscriptionNamespace), + ]); + + if (namespaceCleanup.status === 'rejected') { + throw namespaceCleanup.reason; + } + + if (subscriptionCleanup.status === 'rejected') { + throw subscriptionCleanup.reason; + } + } finally { + await k8sClient.deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + CATALOG_SOURCE_NAMESPACE, + 'catalogsources', + catalogSourceName, + ); + } + }); + + test('displays deprecated badge on operator tile in catalog', async ({ page }) => { + test.skip(isTechPreview, TECH_PREVIEW_SKIP_REASON); + + const catalogPage = new CatalogPage(page); + + await catalogPage.navigateToSoftwareCatalog(OPERATOR_DETAILS_NAMESPACE); + await catalogPage.clickOperatorTab(); + await expect(catalogPage.getCatalogTiles().first()).toBeVisible({ timeout: 60_000 }); + + await catalogPage.toggleSourceFilterByLabel(CATALOG_SOURCE_DISPLAY_NAME); + + await catalogPage.searchOperators('kiali'); + const firstTile = catalogPage.getCatalogTiles().first(); + await expect(firstTile).toBeVisible({ timeout: 60_000 }); + await expect(firstTile).toContainText(/kiali/i); + await expect(firstTile.getByTestId('Deprecated-badge')).toContainText(DEPRECATED_BADGE); + }); + + test('displays package deprecation warnings in operator details', async ({ page }) => { + test.skip(isTechPreview, TECH_PREVIEW_SKIP_REASON); + + const catalogPage = new CatalogPage(page); + await catalogPage.navigateToPath(getOperatorDetailsUrl()); + await expect(catalogPage.getCatalogDeprecatedBadge()).toContainText(DEPRECATED_BADGE, { + timeout: 60_000, + }); + await expectDeprecatedWarning( + catalogPage, + 'deprecated-operator-warning-package', + DEPRECATED_PACKAGE_MESSAGE, + ); + }); + + test('displays channel deprecation warnings when selecting channel', async ({ page }) => { + test.skip(isTechPreview, TECH_PREVIEW_SKIP_REASON); + + const catalogPage = new CatalogPage(page); + const installPage = new OperatorInstallPage(page); + await catalogPage.navigateToPath(getOperatorDetailsUrl()); + + await installPage.openChannelSelect(); + await expect(installPage.getDeprecatedWarningIcon('channel')).toBeVisible({ + timeout: 30_000, + }); + await installPage.selectChannelOption('alpha'); + + await expectDeprecatedWarning( + catalogPage, + 'deprecated-operator-warning-channel', + DEPRECATED_CHANNEL_MESSAGE, + ); + }); + + test('displays version deprecation warnings when selecting version', async ({ page }) => { + test.skip(isTechPreview, TECH_PREVIEW_SKIP_REASON); + + const catalogPage = new CatalogPage(page); + const installPage = new OperatorInstallPage(page); + await catalogPage.navigateToPath(getOperatorDetailsUrl()); + + await installPage.openVersionSelect(); + await expect(installPage.getDeprecatedWarningIcon('version')).toBeVisible({ + timeout: 30_000, + }); + await installPage.selectVersionOption(DEPRECATED_VERSION); + + await expectDeprecatedWarning( + catalogPage, + 'deprecated-operator-warning-version', + DEPRECATED_VERSION_MESSAGE, + ); + }); + + test('displays all deprecation warnings on install page', async ({ page }) => { + test.skip(isTechPreview, TECH_PREVIEW_SKIP_REASON); + + const catalogPage = new CatalogPage(page); + await catalogPage.navigateToPath(getInstallPageUrl()); + // The install/subscribe page shows the deprecation Alert (checked below) but does not + // render a "Deprecated" CatalogBadges badge — that only appears on the catalog tile and + // the catalog details drawer. + await expect(catalogPage.getPageHeading()).toContainText('Install Operator', { + timeout: 60_000, + }); + await expectDeprecatedWarning( + catalogPage, + 'deprecated-operator-warning-package', + DEPRECATED_PACKAGE_MESSAGE, + ); + await expectDeprecatedWarning( + catalogPage, + 'deprecated-operator-warning-channel', + DEPRECATED_CHANNEL_MESSAGE, + ); + await expectDeprecatedWarning( + catalogPage, + 'deprecated-operator-warning-version', + DEPRECATED_VERSION_MESSAGE, + ); + }); + + test.describe('Installed Operator deprecation warnings', () => { + test.beforeAll(async ({ k8sClient }) => { + test.setTimeout(SETUP_TIMEOUT); + + if (isTechPreview) { + return; + } + + await k8sClient.createNamespace(subscriptionNamespace); + await k8sClient.createCustomResource( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'subscriptions', + buildDeprecatedSubscription(), + ); + + await expect(async () => { + const subscription = (await k8sClient.getCustomResource( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'subscriptions', + subscriptionName, + )) as { + status?: { + installPlanRef?: { name?: string }; + }; + }; + + expect(subscription.status?.installPlanRef?.name).toBeTruthy(); + }).toPass({ timeout: 300_000, intervals: [5_000] }); + + const subscription = (await k8sClient.getCustomResource( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'subscriptions', + subscriptionName, + )) as { + status?: { + installPlanRef?: { name?: string }; + installedCSV?: string; + }; + }; + + const installPlanName = subscription.status?.installPlanRef?.name; + + if (!installPlanName) { + throw new Error(`InstallPlan ref not found for subscription ${subscriptionName}`); + } + + await k8sClient.patchCustomResource( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'installplans', + installPlanName, + [{ op: 'replace', path: '/spec/approved', value: true }], + ); + + await expect(async () => { + const approvedSubscription = (await k8sClient.getCustomResource( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'subscriptions', + subscriptionName, + )) as { + status?: { + installedCSV?: string; + }; + }; + + expect(approvedSubscription.status?.installedCSV).toBeTruthy(); + }).toPass({ timeout: 180_000, intervals: [5_000] }); + + const installedSubscription = (await k8sClient.getCustomResource( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'subscriptions', + subscriptionName, + )) as { + status?: { + installedCSV?: string; + }; + }; + + const nextInstalledCsvName = installedSubscription.status?.installedCSV; + if (!nextInstalledCsvName) { + throw new Error(`Installed CSV not found for subscription ${subscriptionName}`); + } + installedCsvName = nextInstalledCsvName; + + await expect(async () => { + const csv = (await k8sClient.getCustomResource( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'clusterserviceversions', + installedCsvName, + )) as { status?: { phase?: string } }; + expect(csv.status?.phase).toBe('Succeeded'); + }).toPass({ timeout: 300_000, intervals: [5_000] }); + + await expect(async () => { + const currentSubscription = (await k8sClient.getCustomResource( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'subscriptions', + subscriptionName, + )) as { + status?: { + conditions?: Array<{ type?: string }>; + }; + }; + + const hasDeprecatedCondition = currentSubscription.status?.conditions?.some( + (condition) => condition.type === 'PackageDeprecated', + ); + expect(hasDeprecatedCondition).toBe(true); + }).toPass({ timeout: 180_000, intervals: [5_000] }); + }); + + test('displays deprecated badge on installed operators list', async ({ page }) => { + test.skip(isTechPreview, TECH_PREVIEW_SKIP_REASON); + + const installedOperatorsPage = new InstalledOperatorsPage(page); + await installedOperatorsPage.navigateTo(subscriptionNamespace); + await installedOperatorsPage.filterByName(INSTALLED_OPERATOR_NAME); + + const operatorRow = installedOperatorsPage.getOperatorRow(INSTALLED_OPERATOR_NAME); + await expect(operatorRow).toBeVisible({ timeout: 60_000 }); + await expect(operatorRow.getByTestId('deprecated-operator-warning-badge')).toContainText( + DEPRECATED_BADGE, + ); + }); + + test('displays deprecation warnings on CSV details page', async ({ page }) => { + test.skip(isTechPreview, TECH_PREVIEW_SKIP_REASON); + + const catalogPage = new CatalogPage(page); + const detailsPage = new DetailsPage(page); + await detailsPage.navigateToDetailsPage( + `/k8s/ns/${subscriptionNamespace}/operators.coreos.com~v1alpha1~ClusterServiceVersion/${installedCsvName}`, + ); + + await expect(detailsPage.tab('Details')).toBeVisible({ timeout: 60_000 }); + await expect(catalogPage.getDeprecatedWarningBadge()).toContainText(DEPRECATED_BADGE, { + timeout: 60_000, + }); + await expectDeprecatedWarning( + catalogPage, + 'deprecated-operator-warning-package', + DEPRECATED_PACKAGE_MESSAGE, + ); + await expectDeprecatedWarning( + catalogPage, + 'deprecated-operator-warning-channel', + DEPRECATED_CHANNEL_MESSAGE, + ); + await expectDeprecatedWarning( + catalogPage, + 'deprecated-operator-warning-version', + DEPRECATED_VERSION_MESSAGE, + ); + }); + + test('displays deprecation warnings on CSV subscription tab', async ({ page }) => { + test.skip(isTechPreview, TECH_PREVIEW_SKIP_REASON); + + const catalogPage = new CatalogPage(page); + const detailsPage = new DetailsPage(page); + await detailsPage.navigateToDetailsPage( + `/k8s/ns/${subscriptionNamespace}/operators.coreos.com~v1alpha1~ClusterServiceVersion/${installedCsvName}/subscription`, + ); + + await expect(detailsPage.tab('Subscription')).toBeVisible({ + timeout: 60_000, + }); + await expectDeprecatedWarning( + catalogPage, + 'deprecated-operator-warning-package', + DEPRECATED_PACKAGE_MESSAGE, + ); + await expectDeprecatedWarning( + catalogPage, + 'deprecated-operator-warning-channel', + DEPRECATED_CHANNEL_MESSAGE, + ); + await expectDeprecatedWarning( + catalogPage, + 'deprecated-operator-warning-version', + DEPRECATED_VERSION_MESSAGE, + ); + await expect(catalogPage.getDeprecatedWarning('deprecated-operator-warning-subscription-update-icon')).toBeVisible({ + timeout: 30_000, + }); + + const updateButton = page.getByTestId('subscription-channel-update-button'); + await expect(updateButton).toBeEnabled({ timeout: 30_000 }); + await updateButton.click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId(LATEST_VERSION_OPTION)).toBeVisible({ timeout: 30_000 }); + }); + }); +}); diff --git a/frontend/e2e/tests/olm/descriptors.spec.ts b/frontend/e2e/tests/olm/descriptors.spec.ts new file mode 100644 index 00000000000..73ae48d5393 --- /dev/null +++ b/frontend/e2e/tests/olm/descriptors.spec.ts @@ -0,0 +1,500 @@ +import { test, expect } from '../../fixtures'; +import { OperandPage } from '../../pages/operand-page'; + +const TEST_SUFFIX = Date.now().toString(); +const CRD_GROUP = `test-${TEST_SUFFIX}.tectonic.com`; +const CRD_NAME = `apps.${CRD_GROUP}`; +const CRD_VERSION = 'v1'; +const CRD_KIND = 'App'; +const CRD_PLURAL = 'apps'; +const CSV_NAME = `olm-descriptors-test-${TEST_SUFFIX}`; +const CR_NAME = `olm-descriptors-test-${TEST_SUFFIX}`; + +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 }) => { + test.setTimeout(180_000); + ns = `test-desc-${Date.now()}`; + await k8sClient.createNamespace(ns); + const namespaceReady = await k8sClient.waitForNamespaceReady(ns); + if (!namespaceReady) { + throw new Error(`Namespace ${ns} did not become ready in time`); + } + + const testCRD = buildTestCRD(); + await k8sClient.createClusterCustomResource( + 'apiextensions.k8s.io', + 'v1', + 'customresourcedefinitions', + testCRD, + ); + + await expect(async () => { + const crd = (await k8sClient.getClusterCustomResource( + 'apiextensions.k8s.io', + 'v1', + 'customresourcedefinitions', + CRD_NAME, + )) as { status?: { conditions?: Array<{ type?: string; status?: string }> } }; + const established = crd.status?.conditions?.some( + (condition) => condition.type === 'Established' && condition.status === 'True', + ); + expect(established).toBe(true); + }).toPass({ timeout: 60_000, intervals: [2_000] }); + + const testCR = buildTestCR(ns); + const testCSV = buildTestCSV(ns, testCR); + await k8sClient.createCustomResource( + 'operators.coreos.com', + 'v1alpha1', + ns, + 'clusterserviceversions', + testCSV, + ); + + await expect(async () => { + const csv = await k8sClient.getCustomResource( + 'operators.coreos.com', + 'v1alpha1', + ns, + 'clusterserviceversions', + CSV_NAME, + ); + expect(csv).toBeTruthy(); + }).toPass({ timeout: 60_000, intervals: [2_000] }); + + 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)).toBeVisible({ timeout: 60_000 }); + }); + + 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..644e17b035b --- /dev/null +++ b/frontend/e2e/tests/olm/edit-default-sources.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '../../fixtures'; +import { OperatorHubDetailsPage } from '../../pages/operator-hub-details-page'; + +test.describe('OperatorHub default sources management', { tag: ['@admin'] }, () => { + let originalSources: Array<{ name?: string; disabled?: boolean }> | null = null; + + test.beforeEach(async ({ k8sClient }) => { + const operatorHub = (await k8sClient.getClusterCustomResource( + 'config.openshift.io', + 'v1', + 'operatorhubs', + 'cluster', + )) as { + spec?: { + sources?: Array<{ name?: string; disabled?: boolean }>; + }; + }; + + originalSources = operatorHub.spec?.sources + ? JSON.parse(JSON.stringify(operatorHub.spec.sources)) + : null; + }); + + test.afterEach(async ({ k8sClient }) => { + await k8sClient.patchClusterCustomResource( + 'config.openshift.io', + 'v1', + 'operatorhubs', + 'cluster', + { spec: { sources: originalSources } }, + ); + }); + + 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 () => { + await operatorHubPage.toggleSourceAndVerify( + defaultSourceToBeToggled, + 'Disabled', + '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..9ad8b928166 --- /dev/null +++ b/frontend/e2e/tests/olm/operator-hub.spec.ts @@ -0,0 +1,106 @@ +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(); + + // Capture the first tile text with Community filter for later comparison + const originalTileText = await catalogPage.getFirstCatalogTileTitleText(); + + // Validate that we captured a valid tile title + expect(originalTileText).toBeTruthy(); + 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 () => { + // Clear the Certified source filter left by previous test + await catalogPage.toggleSourceFilter('certified'); + + const operatorName = (await catalogPage.getFirstCatalogTileTitleText()).trim(); + expect(operatorName).not.toBe(''); + + 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..52de976314f --- /dev/null +++ b/frontend/e2e/tests/olm/operator-install-global.spec.ts @@ -0,0 +1,172 @@ +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'; + +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(`Globally installs ${testOperator.name} operator in ${globalNamespace} and creates ${testOperand.name} operand`, async ({ + page, + k8sClient, + cleanup, + }) => { + const installPage = new OperatorInstallPage(page); + const installedOperatorsPage = new InstalledOperatorsPage(page); + const operatorDetailsPage = new OperatorDetailsPage(page); + const clusterOperatorName = `${operatorPackageName}.${globalNamespace}`; + + await test.step('Ensure the global subscription does not pre-exist', async () => { + try { + await k8sClient.getCustomResource( + 'operators.coreos.com', + 'v1alpha1', + globalNamespace, + 'subscriptions', + operatorPackageName, + ); + test.skip( + true, + `${operatorPackageName} subscription already exists in ${globalNamespace}; cannot safely claim ownership for cleanup`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes('404') && !message.includes('not found')) { + throw error; + } + } + + try { + await k8sClient.getClusterCustomResource( + 'operators.coreos.com', + 'v1', + 'operators', + clusterOperatorName, + ); + test.skip( + true, + `${clusterOperatorName} already exists; cannot safely claim ownership for cleanup`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes('404') && !message.includes('not found')) { + throw error; + } + } + }); + + cleanup.trackCustomResource( + operatorPackageName, + globalNamespace, + 'operators.coreos.com', + 'v1alpha1', + 'subscriptions', + ); + cleanup.trackClusterCustomResource( + clusterOperatorName, + 'operators.coreos.com', + 'v1', + 'operators', + ); + + 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); + + const subscription = (await k8sClient.getCustomResource( + 'operators.coreos.com', + 'v1alpha1', + globalNamespace, + 'subscriptions', + operatorPackageName, + )) as { + status?: { installPlanRef?: { name?: string }; installedCSV?: string }; + }; + + const installPlanName = subscription.status?.installPlanRef?.name; + const installedCsvName = subscription.status?.installedCSV; + if (!installPlanName) { + throw new Error(`InstallPlan ref not found for ${operatorPackageName} subscription`); + } + if (!installedCsvName) { + throw new Error(`Installed CSV not found for ${operatorPackageName} subscription`); + } + + cleanup.trackCustomResource( + installPlanName, + globalNamespace, + 'operators.coreos.com', + 'v1alpha1', + 'installplans', + ); + cleanup.trackCustomResource( + installedCsvName, + globalNamespace, + 'operators.coreos.com', + 'v1alpha1', + 'clusterserviceversions', + ); + }); + + 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 () => { + // Track the operand that will be created + cleanup.trackCustomResource( + testOperand.exampleName, + globalNamespace, + testOperand.group, + testOperand.version, + 'infinispans' + ); + + 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..076100d2857 --- /dev/null +++ b/frontend/e2e/tests/olm/operator-install-single-namespace.spec.ts @@ -0,0 +1,151 @@ +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 { generateTestNamespace } from '../../test-utils/test-namespace'; + +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(`Single Namespace Operator Installation - ${testOperator.name}`, { tag: ['@admin'] }, () => { + test.describe.configure({ timeout: 300_000 }); + + 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(); + cleanup.trackNamespace(testNamespace); + + // Track the subscription that will be created + cleanup.trackCustomResource( + operatorPackageName, + testNamespace, + 'operators.coreos.com', + 'v1alpha1', + 'subscriptions' + ); + + await test.step('Ensure no conflicting global subscription pre-exists', async () => { + // AllNamespaces install in openshift-operators disables Install for all namespaces. + try { + await k8sClient.getCustomResource( + 'operators.coreos.com', + 'v1alpha1', + globalNamespace, + 'subscriptions', + operatorPackageName, + ); + test.skip( + true, + `${operatorPackageName} is globally installed in ${globalNamespace}; cannot install as single-namespace in parallel`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes('404') && !message.includes('not found')) { + throw error; + } + } + }); + + await test.step('Install operator in new test namespace', async () => { + try { + 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 () => { + // Track the operand that will be created + cleanup.trackCustomResource( + testOperand.exampleName, + testNamespace, + testOperand.group, + testOperand.version, + 'infinispans' + ); + + 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-lifecycle-metadata.spec.ts b/frontend/e2e/tests/olm/operator-lifecycle-metadata.spec.ts index 1a859d7c975..aadcef649f0 100644 --- a/frontend/e2e/tests/olm/operator-lifecycle-metadata.spec.ts +++ b/frontend/e2e/tests/olm/operator-lifecycle-metadata.spec.ts @@ -156,7 +156,7 @@ test.describe('Operator lifecycle metadata', { tag: ['@admin'] }, () => { const serverFlags = await page.evaluate(() => window.SERVER_FLAGS); test.skip( - !serverFlags.olmLifecycleMetadata, + !serverFlags?.olmLifecycleMetadata, 'Lifecycle metadata columns require the OLMLifecycleAndCompatibility feature gate', ); const releaseVersion = serverFlags.releaseVersion ?? ''; 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..b54fd5f994c --- /dev/null +++ b/frontend/e2e/tests/olm/operator-uninstall.spec.ts @@ -0,0 +1,151 @@ +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 { generateTestNamespace } from '../../test-utils/test-namespace'; + +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'; + +test.describe('Testing uninstall of Data Grid Operator', { tag: ['@admin'] }, () => { + test.describe.configure({ timeout: 300_000 }); + + + 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(); + cleanup.trackNamespace(testNamespace); + + // Track the subscription that will be created + cleanup.trackCustomResource( + operatorPackageName, + testNamespace, + 'operators.coreos.com', + 'v1alpha1', + 'subscriptions' + ); + + await test.step('Install operator in new test namespace', async () => { + try { + 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 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); + + // Track the operand that will be created + cleanup.trackCustomResource( + testOperand.exampleName, + testNamespace, + testOperand.group, + testOperand.version, + 'infinispans' + ); + + // 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 () => { + await expect(async () => { + try { + await k8sClient.getCustomResource( + testOperand.group, + testOperand.version, + testNamespace, + 'infinispans', + testOperand.exampleName + ); + throw new Error('Operand still exists'); + } catch (error) { + if (error.message?.includes('404') || error.message?.includes('not found')) { + return; // Success - operand is deleted + } + throw error; + } + }).toPass({ timeout: 120_000, intervals: [5_000] }); + }); + + }); + + test.fixme( + 'tracks missing "Error Deleting Operands" uninstall parity case', + async () => { + expect(true).toBe(true); + }, + ); +}); 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..ce0fc7023d0 --- /dev/null +++ b/frontend/e2e/tests/olm/packageserver-tabs.spec.ts @@ -0,0 +1,119 @@ +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 sectionHeader = 'PackageManifest overview'; + let packageManifestName: string; + let baseUrl: string; + + test.beforeAll(async ({ k8sClient }) => { + const packageManifests = (await k8sClient.listCustomResources( + 'packages.operators.coreos.com', + 'v1', + csvNamespace, + 'packagemanifests', + )) as Array<{ metadata?: { name?: string } }>; + + packageManifestName = packageManifests.find((manifest) => manifest.metadata?.name)?.metadata?.name ?? ''; + if (!packageManifestName) { + throw new Error(`No PackageManifest resources found in namespace ${csvNamespace}`); + } + + baseUrl = `/k8s/ns/${csvNamespace}/operators.coreos.com~v1alpha1~ClusterServiceVersion/${csvName}/packages.operators.coreos.com~v1~PackageManifest/${packageManifestName}`; + }); + + test('renders Details tab correctly', async ({ page }) => { + const detailsPage = new DetailsPage(page); + + await test.step('Navigate to PackageManifest Details tab', async () => { + await detailsPage.navigateToDetailsUrl(baseUrl); + }); + + await test.step('Verify page title shows package name', async () => { + await expect(detailsPage.title).toContainText(packageManifestName); + }); + + await test.step('Verify Details section header exists', async () => { + await expect(detailsPage.getSectionHeader(sectionHeader)).toBeVisible(); + }); + }); + + test('renders YAML tab correctly', async ({ page }) => { + const yamlEditor = new YamlEditorPage(page); + + await test.step('Navigate to PackageManifest YAML tab', async () => { + await yamlEditor.navigateToYamlUrl(`${baseUrl}/yaml`); + }); + + await test.step('Verify YAML contains package manifest metadata', async () => { + const content = await yamlEditor.getEditorContent(); + expect(content).toContain(packageManifestName); + expect(content).toContain('PackageManifest'); + }); + }); + + test('renders Resources tab correctly', async ({ page }) => { + const detailsPage = new DetailsPage(page); + + await test.step('Navigate to PackageManifest Resources tab', async () => { + await detailsPage.navigateToDetailsUrl(`${baseUrl}/resources`); + }); + + await test.step('Verify resource list is empty', async () => { + await expect(detailsPage.getEmptyState()).toBeVisible(); + }); + }); + + test('renders Events tab correctly', async ({ page }) => { + const detailsPage = new DetailsPage(page); + + await test.step('Navigate to PackageManifest Events tab', async () => { + await detailsPage.navigateToDetailsUrl(`${baseUrl}/events`); + }); + + await test.step('Verify events stream component is empty', async () => { + 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/e2e/utils/selector-utils.ts b/frontend/e2e/utils/selector-utils.ts new file mode 100644 index 00000000000..680f3116fc6 --- /dev/null +++ b/frontend/e2e/utils/selector-utils.ts @@ -0,0 +1,5 @@ +export const escapeRegExp = (value: string): string => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +export const quoteAttributeValue = (value: string): string => + value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); 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/console-shared/src/components/namespace/NamespaceDropdown.tsx b/frontend/packages/console-shared/src/components/namespace/NamespaceDropdown.tsx index 02831fba140..47f266c820a 100644 --- a/frontend/packages/console-shared/src/components/namespace/NamespaceDropdown.tsx +++ b/frontend/packages/console-shared/src/components/namespace/NamespaceDropdown.tsx @@ -177,6 +177,7 @@ export const Footer: FC<{ setOpen(false); onCreateNew(); }} + data-test="#CREATE_RESOURCE_ACTION#" data-test-dropdown-menu="#CREATE_RESOURCE_ACTION#" > {isProject ? t('Create Project') : t('Create Namespace')} diff --git a/frontend/packages/operator-lifecycle-manager/integration-tests/mocks/index.tsx b/frontend/packages/operator-lifecycle-manager/integration-tests/mocks/index.tsx index f45748c1bc9..ccd3b9f0b78 100644 --- a/frontend/packages/operator-lifecycle-manager/integration-tests/mocks/index.tsx +++ b/frontend/packages/operator-lifecycle-manager/integration-tests/mocks/index.tsx @@ -314,40 +314,3 @@ export const testCatalogSource = { }, }, }; - -export const testDeprecatedCatalogSource = { - kind: 'CatalogSource', - apiVersion: 'operators.coreos.com/v1alpha1', - metadata: { - name: 'test-community-operator-deprecation', - namespace: 'openshift-marketplace', - }, - spec: { - displayName: 'Community Operators for testing deprecation', - image: 'quay.io/cajieh0/deprecation-catalog', - publisher: 'OLM community', - sourceType: 'grpc', - updateStrategy: { - registryPoll: { - interval: '10m', - }, - }, - }, -}; - -export const testDeprecatedSubscription = { - apiVersion: 'operators.coreos.com/v1alpha1', - kind: 'Subscription', - metadata: { - name: 'kiali', - namespace: 'openshift-operators', - }, - spec: { - source: 'test-community-operator-deprecation', - sourceNamespace: 'openshift-marketplace', - name: 'kiali', - startingCSV: 'kiali-operator.v1.68.0', - channel: 'alpha', - installPlanApproval: 'Manual', - }, -}; 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/integration-tests/tests/descriptors.cy.ts b/frontend/packages/operator-lifecycle-manager/integration-tests/tests/descriptors.cy.ts deleted file mode 100644 index 73174666fa4..00000000000 --- a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/descriptors.cy.ts +++ /dev/null @@ -1,136 +0,0 @@ -import * as _ from 'lodash'; -import { checkErrors, create, testName } from '@console/cypress-integration-tests/support'; -import { testCR, testCRD, testCSV } from '../mocks'; - -describe('Using OLM descriptor components', () => { - before(() => { - cy.createProjectWithCLI(testName); - create(testCRD); - create(testCSV); - }); - - beforeEach(() => { - cy.login(); - cy.initAdmin(); - }); - - afterEach(() => { - cy.visit('/'); - cy.exec( - `oc delete ${testCRD.spec.names.kind} ${testCR.metadata.name} -n ${testName} --ignore-not-found=true`, - ); - checkErrors(); - }); - - after(() => { - cy.exec(`oc delete crd ${testCRD.metadata.name}`); - cy.exec(`oc delete -n ${testName} clusterserviceversion ${testCSV.metadata.name}`); - cy.deleteProjectWithCLI(testName); - }); - - const ARRAY_FIELD_GROUP_ID = 'root_spec_arrayFieldGroup'; - const FIELD_GROUP_ID = 'root_spec_fieldGroup'; - const LABELS_FIELD_ID = 'root_metadata_labels'; - const NAME_FIELD_ID = 'root_metadata_name'; - const NUMBER_FIELD_ID = 'root_spec_number'; - const PASSWORD_FIELD_ID = 'root_spec_password'; - const SELECT_FIELD_ID = 'root_spec_select'; - const atomicFields = [ - { - label: 'Name', - path: 'metadata.name', - id: NAME_FIELD_ID, - }, - { - label: 'Password', - path: 'spec.password', - id: PASSWORD_FIELD_ID, - }, - { - label: 'Number', - path: 'spec.number', - id: NUMBER_FIELD_ID, - }, - ]; - const getOperandFormFieldElement = (id) => cy.get(`#${id}_field`); - const getOperandFormFieldLabel = (id) => cy.get(`[for=${id}]`); - const getOperandFormFieldInput = (id) => cy.get(`#${id}`); - - const { - group, - names: { kind }, - } = testCRD.spec; - const version = testCRD.spec.versions[0].name; - const URL = `/k8s/ns/${testName}/operators.coreos.com~v1alpha1~ClusterServiceVersion/${testCSV.metadata.name}/${group}~${version}~${kind}`; - - it('displays list and detail views of an operand', () => { - create(testCR); - cy.visit(URL); - cy.byTestOperandLink('olm-descriptors-test').should('exist'); - cy.visit(`${URL}/${testCR.metadata.name}`); - cy.byLegacyTestID('resource-title').should('have.text', `${testCR.metadata.name}`); - testCSV.spec.customresourcedefinitions.owned[0].specDescriptors.forEach((descriptor) => { - if (descriptor.path === 'hidden') { - cy.byTestSelector(`details-item-label__${descriptor.displayName}`).should('not.exist'); - } else { - cy.byTestSelector(`details-item-label__${descriptor.displayName}`).should('exist'); - } - }); - testCSV.spec.customresourcedefinitions.owned[0].statusDescriptors - // exclude Conditions since they are included in their own section - .filter((descriptor) => descriptor.path !== 'conditions') - .forEach((descriptor) => { - if (descriptor.path === 'hidden') { - cy.byTestSelector(`details-item-label__${descriptor.displayName}`).should('not.exist'); - } else { - cy.byTestSelector(`details-item-label__${descriptor.displayName}`).should('exist'); - } - }); - }); - - it('creates an operand using the form', () => { - cy.visit(URL); - // TODO figure out why this element is detaching - cy.byTestID('item-create').click({ force: true }); - cy.get('[data-test="page-heading"] h1').should('have.text', 'Create App'); - // TODO: implement tests for more descriptor-based form fields and widgets as well as data syncing. - atomicFields.forEach(({ label, id, path }) => { - getOperandFormFieldElement(id).should('exist'); - getOperandFormFieldLabel(id).should('have.text', label); - getOperandFormFieldInput(id).should('have.value', _.get(testCR, path).toString()); - }); - getOperandFormFieldElement(SELECT_FIELD_ID).should('exist'); - getOperandFormFieldLabel(SELECT_FIELD_ID).should('have.text', 'Select'); - cy.get(`#${SELECT_FIELD_ID}`).should('have.text', testCR?.spec?.select.toString()); - getOperandFormFieldElement(LABELS_FIELD_ID).should('exist'); - getOperandFormFieldLabel(LABELS_FIELD_ID).should('have.text', 'Labels'); - cy.get(`#${LABELS_FIELD_ID}_field .tag-item-content`).should( - 'have.text', - `automatedTestName=${testName}`, - ); - cy.get(`#${FIELD_GROUP_ID}_field-group`).should('exist'); - cy.get(`#${FIELD_GROUP_ID}_accordion-toggle`).click(); - cy.get(`[for="${FIELD_GROUP_ID}_itemOne"]`).should('have.text', 'itemOne'); - cy.get(`#${FIELD_GROUP_ID}_itemOne`).should('have.value', testCR.spec.fieldGroup.itemOne); - cy.get(`[for="${FIELD_GROUP_ID}_itemTwo"]`).should('have.text', 'itemTwo'); - cy.get(`#${FIELD_GROUP_ID}_itemTwo`).should('have.value', testCR.spec.fieldGroup.itemTwo); - cy.get(`#${ARRAY_FIELD_GROUP_ID}_field-group`).should('exist'); - cy.get(`#${ARRAY_FIELD_GROUP_ID}_accordion-toggle`).click(); - cy.get(`[for="${ARRAY_FIELD_GROUP_ID}_0_itemOne"]`).should('have.text', 'Item One'); - cy.get(`#${ARRAY_FIELD_GROUP_ID}_0_itemOne`).should( - 'have.value', - testCR.spec.arrayFieldGroup[0].itemOne, - ); - cy.get(`[for="${ARRAY_FIELD_GROUP_ID}_0_itemTwo"]`).should('have.text', 'Item Two'); - cy.get(`#${ARRAY_FIELD_GROUP_ID}_0_itemTwo`).should( - 'have.value', - testCR.spec.arrayFieldGroup[0].itemTwo, - ); - cy.get('#root_spec_hiddenFieldGroup_field-group').should('not.exist'); - cy.get('#root_metadata_name').clear().type(testCR.metadata.name); - cy.byTestID('create-dynamic-form').click(); - // TODO figure out why this element is detaching - cy.byTestOperandLink(testCR.metadata.name).click({ force: true }); - cy.byTestID('operand-details__section--info').should('exist'); - }); -}); diff --git a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/edit-default-sources.cy.ts b/frontend/packages/operator-lifecycle-manager/integration-tests/tests/edit-default-sources.cy.ts deleted file mode 100644 index 34d4b5e9933..00000000000 --- a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/edit-default-sources.cy.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { checkErrors } 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'; - -describe('Create namespace from install operators', () => { - before(() => { - cy.login(); - }); - - beforeEach(() => { - cy.initAdmin(); - }); - - afterEach(() => { - checkErrors(); - }); - - it('disables default catalog sources from OperatorHub details page', () => { - cy.log('navigate to OperatorHub page'); - cy.visit(`/settings/cluster`); - cy.byLegacyTestID('horizontal-link-Configuration').click(); - cy.byLegacyTestID('OperatorHub').click(); - - // verfiy OperatorHub details page is open - detailsPage.sectionHeaderShouldExist('OperatorHub details'); - - // Toggle default sources modal - const defaultSourceToBeToggled = 'redhat-operators'; - cy.byTestID('Default sources-details-item__edit-button').click(); - modal.modalTitleShouldContain('Edit default sources'); - cy.byTestID(`${defaultSourceToBeToggled}__checkbox`).click(); - modal.submit(); - - // Verify status change - cy.byTestID(`status_${defaultSourceToBeToggled}`).should('have.text', 'Disabled'); - - // switch the toggle back to previous state - cy.byTestID('Default sources-details-item__edit-button').click(); - modal.modalTitleShouldContain('Edit default sources'); - cy.byTestID(`${defaultSourceToBeToggled}__checkbox`).click(); - modal.submit(); - - // Verify status change - cy.byTestID(`status_${defaultSourceToBeToggled}`).should('have.text', 'Enabled'); - }); -}); diff --git a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/operator-hub.cy.ts b/frontend/packages/operator-lifecycle-manager/integration-tests/tests/operator-hub.cy.ts deleted file mode 100644 index cf029e7963c..00000000000 --- a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/operator-hub.cy.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { checkErrors, testName } from '@console/cypress-integration-tests/support'; - -describe('Interacting with Operators', () => { - 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(); - } - }); - cy.createProjectWithCLI(testName); - }); - - afterEach(() => { - checkErrors(); - }); - - after(() => { - cy.deleteProjectWithCLI(testName); - }); - - it('displays Operator catalog items with expected available Operators', () => { - cy.log('navigate to Software Catalog'); - cy.visit(`/catalog/ns/${testName}`); - cy.byTestID('page-heading').should('contain.text', 'Software Catalog'); - cy.log('more than one tile should be present'); - cy.byTestID('tab operator').click(); - cy.get('.co-catalog-tile').its('length').should('be.gt', 0); - cy.log('enable the Community filter'); - cy.byTestID('source-community').click(); - cy.log('more than one tile should be present'); - cy.get('.co-catalog-tile').its('length').should('be.gt', 0); - cy.log('track which tile is first'); - // eslint-disable-next-line promise/catch-or-return - cy.get('.co-catalog-tile') - .first() - .then(($origCatalogTitle) => { - const origCatalogTitleTxt = $origCatalogTitle.find('.catalog-tile-pf-title').text(); - cy.log(`first Community filtered tile title text is ${origCatalogTitleTxt}`); - cy.log('disable the Community filter'); - cy.byTestID('source-community').click(); - cy.log('enable the Certified filter'); - cy.byTestID('source-certified').click(); - cy.log('more than one tile should be present'); - cy.get('.co-catalog-tile').its('length').should('be.gt', 0); - cy.log('the first tile title text for Certified should not be the same as Community'); - // Wait for catalog to re-render with new filter - under React 18 concurrent rendering, - // tile updates are batched and happen asynchronously. Use .should() callback to retry - // until the tile content actually changes. - cy.get('.co-catalog-tile') - .first() - .find('.catalog-tile-pf-title') - .should(($title) => { - const newTitleTxt = $title.text(); - expect(newTitleTxt).not.to.equal(origCatalogTitleTxt); - }); - }); - - cy.log('filters Operators by name'); - const operatorName = 'Datadog Operator'; - cy.byTestID('search-catalog').type(operatorName); - cy.get('.co-catalog-tile').its('length').should('be.gt', 0); - cy.get('.co-catalog-tile') - .first() - .find('.catalog-tile-pf-title') - .should('have.text', operatorName); - cy.byTestID('search-catalog').find('input').clear(); - - cy.log('displays "Clear All Filters" link when text filter removes all Operators from display'); - cy.log('enter a search query that will return zero results'); - cy.byTestID('search-catalog').type('NoOperatorsTest'); - cy.get('.co-catalog-tile').should('not.exist'); - cy.byLegacyTestID('catalog-clear-filters').should('exist'); - - cy.log('clears text filter when "Clear All Filters" link is clicked'); - cy.byLegacyTestID('catalog-clear-filters').click(); - cy.byTestID('search-catalog').get('input').should('be.empty'); - cy.get('.co-catalog-tile').its('length').should('be.gt', 0); - - cy.log('filters Operators by category'); - const filterLabel = 'tab ai/machine learning'; - cy.log(`click the ${filterLabel} filter`); - cy.get(`[data-test="${filterLabel}"] > a`).click(); - cy.get('.co-catalog-tile').its('length').should('be.gt', 0); - }); -}); diff --git a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/operator-install-global.cy.ts b/frontend/packages/operator-lifecycle-manager/integration-tests/tests/operator-install-global.cy.ts deleted file mode 100644 index d4048f79f27..00000000000 --- a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/operator-install-global.cy.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { checkErrors } from '@console/cypress-integration-tests/support'; -import type { TestOperandProps } from '../views/operator.view'; -import { operator, GlobalInstalledNamespace } from '../views/operator.view'; - -const testOperator = { - name: 'Data Grid', - operatorCardTestID: 'operator-Data Grid', -}; - -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 cleanupOperatorResources = () => { - // Clean up operand instances first - cy.exec( - `oc delete infinispan.infinispan.org ${testOperand.exampleName} -n ${GlobalInstalledNamespace} --ignore-not-found`, - { failOnNonZeroExit: false, timeout: 60000 }, - ); - cy.exec( - `oc delete subscription -l operators.coreos.com/${operatorPackageName}.${GlobalInstalledNamespace} -n ${GlobalInstalledNamespace} --ignore-not-found`, - { failOnNonZeroExit: false, timeout: 120000 }, - ); - cy.exec( - `oc delete csv -l operators.coreos.com/${operatorPackageName}.${GlobalInstalledNamespace} -n ${GlobalInstalledNamespace} --ignore-not-found`, - { failOnNonZeroExit: false, timeout: 120000 }, - ); - cy.exec( - `oc delete installplan -l operators.coreos.com/${operatorPackageName}.${GlobalInstalledNamespace} -n ${GlobalInstalledNamespace} --ignore-not-found`, - { failOnNonZeroExit: false, timeout: 120000 }, - ); -}; - -describe(`Globally installing "${testOperator.name}" operator in ${GlobalInstalledNamespace}`, () => { - 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(); - } - }); - cleanupOperatorResources(); - operator.install(testOperator.name, testOperator.operatorCardTestID); - }); - - afterEach(() => { - checkErrors(); - }); - - after(() => { - cleanupOperatorResources(); - }); - - it(`Globally installs ${testOperator.name} operator in ${GlobalInstalledNamespace} and creates ${testOperand.name} operand`, () => { - operator.installedSucceeded(testOperator.name); - operator.navToDetailsPage(testOperator.name); - cy.byTestSectionHeading('Provided APIs', { timeout: 60000 }).should('exist'); - cy.byTestSectionHeading('ClusterServiceVersion details', { timeout: 30000 }).should('exist'); - cy.byLegacyTestID('resource-summary', { timeout: 30000 }).should('exist'); - - operator.createOperand(testOperator.name, testOperand); - cy.byTestID(testOperand.exampleName).should('exist'); - operator.operandShouldExist(testOperator.name, testOperand); - - operator.deleteOperand(testOperator.name, testOperand); - operator.operandShouldNotExist(testOperator.name, testOperand); - - operator.uninstall(testOperator.name); - operator.shouldNotExist(testOperator.name); - }); -}); diff --git a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/operator-install-single-namespace.cy.ts b/frontend/packages/operator-lifecycle-manager/integration-tests/tests/operator-install-single-namespace.cy.ts deleted file mode 100644 index 8eb8e9eab14..00000000000 --- a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/operator-install-single-namespace.cy.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { checkErrors, testName } from '@console/cypress-integration-tests/support'; -import { projectDropdown } from '@console/cypress-integration-tests/views/common'; -import { nav } from '@console/cypress-integration-tests/views/nav'; -import type { TestOperandProps } from '../views/operator.view'; -import { GlobalInstalledNamespace, operator } from '../views/operator.view'; - -const testOperator = { - name: 'Data Grid', - operatorCardTestID: 'operator-Data Grid', - installedNamespace: testName, -}; - -const testOperand: TestOperandProps = { - name: 'Backup', - group: 'infinispan.org', - version: 'v1', - kind: 'Backup', - exampleName: 'example-backup', -}; - -const operatorPackageName = 'datagrid'; - -const cleanupOperatorResources = (namespace: string) => { - // Clean up operand instances first - cy.exec( - `oc delete backup.infinispan.org ${testOperand.exampleName} -n ${namespace} --ignore-not-found`, - { failOnNonZeroExit: false, timeout: 60000 }, - ); - cy.exec( - `oc delete subscription -l operators.coreos.com/${operatorPackageName}.${namespace} -n ${namespace} --ignore-not-found`, - { failOnNonZeroExit: false, timeout: 120000 }, - ); - cy.exec( - `oc delete csv -l operators.coreos.com/${operatorPackageName}.${namespace} -n ${namespace} --ignore-not-found`, - { failOnNonZeroExit: false, timeout: 120000 }, - ); - cy.exec( - `oc delete installplan -l operators.coreos.com/${operatorPackageName}.${namespace} -n ${namespace} --ignore-not-found`, - { failOnNonZeroExit: false, timeout: 120000 }, - ); -}; - -describe(`Installing "${testOperator.name}" operator in test namespace`, () => { - 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(); - } - }); - cy.createProjectWithCLI(testName); - cleanupOperatorResources(testName); - }); - - afterEach(() => { - checkErrors(); - }); - - after(() => { - cleanupOperatorResources(testName); - cy.deleteProjectWithCLI(testName); - }); - - it(`Installs ${testOperator.name} operator in test namespace and creates ${testOperand.name} operand instance`, () => { - operator.install( - testOperator.name, - testOperator.operatorCardTestID, - testOperator.installedNamespace, - ); - operator.installedSucceeded(testOperator.name, testName); - - operator.navToDetailsPage(testOperator.name, testOperator.installedNamespace); - cy.byTestSectionHeading('Provided APIs', { timeout: 60000 }).should('exist'); - cy.byTestSectionHeading('ClusterServiceVersion details', { timeout: 30000 }).should('exist'); - cy.byLegacyTestID('resource-summary', { timeout: 30000 }).should('exist'); - - // should not be installed Globally - cy.log( - `Operator "${testOperator.name}" should not be installed in "${GlobalInstalledNamespace}"`, - ); - nav.sidenav.clickNavLink(['Ecosystem', 'Installed Operators']); - projectDropdown.selectProject(GlobalInstalledNamespace); - projectDropdown.shouldContain(GlobalInstalledNamespace); - cy.get('.loading-skeleton--table').should('not.exist'); - cy.byTestID('console-empty-state').should('contain', 'No Operators found'); - - operator.createOperand(testOperator.name, testOperand, testOperator.installedNamespace); - cy.byTestID(testOperand.exampleName).should('exist'); - operator.operandShouldExist(testOperator.name, testOperand, testOperator.installedNamespace); - - operator.deleteOperand(testOperator.name, testOperand, testOperator.installedNamespace); - operator.operandShouldNotExist(testOperator.name, testOperand, testOperator.installedNamespace); - - operator.uninstall(testOperator.name, testOperator.installedNamespace); - operator.shouldNotExist(testOperator.name, testOperator.installedNamespace); - }); -}); diff --git a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/operator-uninstall.cy.ts b/frontend/packages/operator-lifecycle-manager/integration-tests/tests/operator-uninstall.cy.ts deleted file mode 100644 index 128a1f9cb3f..00000000000 --- a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/operator-uninstall.cy.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { checkErrors, testName } from '@console/cypress-integration-tests/support'; -import { submitButton } from '@console/cypress-integration-tests/views/form'; -import { modal } from '@console/cypress-integration-tests/views/modal'; -import type { TestOperandProps } from '../views/operator.view'; -import { operator } from '../views/operator.view'; - -const testOperator = { - name: 'Data Grid', - operatorCardTestID: 'operator-Data Grid', - installedNamespace: testName, -}; - -const testOperand: TestOperandProps = { - name: 'Backup', - group: 'infinispan.org', - version: 'v1', - kind: 'Backup', - exampleName: 'example-backup', -}; - -const alertExists = (titleText: string) => { - cy.get('.co-alert').contains(titleText).should('exist'); -}; - -describe(`Testing uninstall of ${testOperator.name} Operator`, () => { - 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(); - } - }); - cy.createProjectWithCLI(testName); - operator.install( - testOperator.name, - testOperator.operatorCardTestID, - testOperator.installedNamespace, - ); - operator.installedSucceeded(testOperator.name, testName); - operator.createOperand(testOperator.name, testOperand, testOperator.installedNamespace); - cy.byTestID(testOperand.exampleName).should('exist'); - operator.operandShouldExist(testOperator.name, testOperand, testOperator.installedNamespace); - }); - - beforeEach(() => { - operator.navToDetailsPage(testOperator.name, testOperator.installedNamespace); - }); - - afterEach(() => { - checkErrors(); - }); - - after(() => { - cy.deleteProjectWithCLI(testName); - }); - - it(`installs ${testOperator.name} Operator and ${testOperand.name} Instance, then navigates to Operator details page`, () => { - cy.byTestSectionHeading('Provided APIs', { timeout: 60000 }).should('exist'); - cy.byTestSectionHeading('ClusterServiceVersion details', { timeout: 30000 }).should('exist'); - cy.byLegacyTestID('resource-summary', { timeout: 30000 }).should('exist'); - }); - - it(`attempts to uninstall the Operator, shows 'Cannot load Operands' alert`, () => { - // return a static error response - cy.intercept('GET', 'api/olm/list-operands?*', { - statusCode: 400, // Bad Request - body: { error: 'Failed to list operands' }, - }).as('listOperands'); - - cy.log('attempt to uninstall the Operator'); - operator.uninstallModal.open(testOperator.name, testOperator.installedNamespace); - - cy.wait('@listOperands'); - alertExists('Cannot load Operands'); - modal.cancel(); - modal.shouldBeClosed(); - }); - - it(`attempts to uninstall the Operator and delete all Operand Instances, shows 'Error Deleting Operands' alert`, () => { - // invalidate the request so operand instance doesn't get deleted and error alert is shown - cy.intercept( - 'DELETE', - `/api/kubernetes/apis/infinispan.org/*/namespaces/${testName}/backups/*`, - (req) => { - req.destroy(); - }, - ).as('deleteOperandInstance'); - - cy.log('attempt uninstall the Operator and all Operand Instances'); - operator.uninstallModal.open(testOperator.name, testOperator.installedNamespace); - operator.uninstallModal.checkDeleteAllOperands(); - modal.submit(true); - cy.wait('@deleteOperandInstance'); - alertExists('Error uninstalling Operator'); - alertExists('Error deleting Operands'); - cy.get(submitButton).contains('OK'); // test change from 'Uninstall' - modal.cancel(); - modal.shouldBeClosed(); - }); - - // This is against Cypress best practices of test independence, but cy.intercepts are only cleared - // before each test -they are not cleared before after[all]() hook, which is where this should exist - // this might be addressed in Cypress v7.0 - it(`successfully uninstalls Operator and deletes all Operands`, () => { - cy.log('uninstall the Operator and all Operand instances'); - operator.uninstall(testOperator.name, testOperator.installedNamespace, true); - cy.log(`verify the Operator is not installed`); - operator.shouldNotExist(testOperator.name, testOperator.installedNamespace); - cy.log('verify operand instance is deleted or marked for deletion'); - cy.resourceShouldBeDeleted(testName, testOperand.kind, testOperand.exampleName); - }); -}); diff --git a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/packageserver-tabs.cy.ts b/frontend/packages/operator-lifecycle-manager/integration-tests/tests/packageserver-tabs.cy.ts deleted file mode 100644 index 70ed43dd679..00000000000 --- a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/packageserver-tabs.cy.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { checkErrors } from '@console/cypress-integration-tests/support'; -import { detailsPage } from '@console/cypress-integration-tests/views/details-page'; -import * as yamlEditor from '@console/cypress-integration-tests/views/yaml-editor'; - -describe('packageserver PackageManifest tabs rendering', () => { - 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'; - - before(() => { - cy.login(); - }); - - afterEach(() => { - checkErrors(); - }); - - it('renders Details tab correctly', () => { - cy.log('navigate to PackageManifest Details tab'); - cy.visit(baseUrl); - - cy.log('verify page loads successfully'); - detailsPage.isLoaded(); - - cy.log('verify page title shows package name'); - detailsPage.titleShouldContain(packageManifestName); - - cy.log('verify Details section header exists'); - detailsPage.sectionHeaderShouldExist(sectionHeader); - }); - - it('renders YAML tab correctly', () => { - cy.log('navigate to PackageManifest YAML tab'); - cy.visit(`${baseUrl}/yaml`); - - cy.log('verify YAML editor loads'); - yamlEditor.isLoaded(); - - cy.log('verify YAML contains package manifest metadata'); - // eslint-disable-next-line promise/catch-or-return - yamlEditor.getEditorContent().then((content) => { - expect(content).to.include(packageManifestName); - expect(content).to.include('PackageManifest'); - }); - }); - - it('renders Resources tab correctly', () => { - cy.log('navigate to PackageManifest Resources tab'); - cy.visit(`${baseUrl}/resources`); - - cy.log('verify page loads successfully'); - detailsPage.isLoaded(); - - cy.log('verify resource list is empty'); - cy.byTestID('console-empty-state').should('exist'); - }); - - it('renders Events tab correctly', () => { - cy.log('navigate to PackageManifest Events tab'); - cy.visit(`${baseUrl}/events`); - - cy.log('verify page loads successfully'); - detailsPage.isLoaded(); - - cy.log('verify events stream component is empty'); - cy.byTestID('console-empty-state').should('exist'); - }); - - it('allows navigation between tabs', () => { - cy.log('start at Details tab'); - cy.visit(baseUrl); - detailsPage.isLoaded(); - - cy.log('navigate to YAML tab'); - detailsPage.selectTab('YAML'); - yamlEditor.isLoaded(); - cy.url().should('include', '/yaml'); - - cy.log('navigate to Resources tab'); - detailsPage.selectTab('Resources'); - detailsPage.isLoaded(); - cy.url().should('include', '/resources'); - cy.byTestID('console-empty-state').should('exist'); - - cy.log('navigate to Events tab'); - detailsPage.selectTab('Events'); - detailsPage.isLoaded(); - cy.url().should('include', '/events'); - cy.byTestID('console-empty-state').should('exist'); - - cy.log('navigate back to Details tab'); - detailsPage.selectTab('Details'); - detailsPage.isLoaded(); - cy.url().should('not.include', '/yaml'); - cy.url().should('not.include', '/resources'); - cy.url().should('not.include', '/events'); - detailsPage.sectionHeaderShouldExist(sectionHeader); - }); -}); 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