From d30ada51459c1c6f531c34b2b248369b388deeb7 Mon Sep 17 00:00:00 2001 From: Robert Luby Date: Fri, 15 May 2026 14:08:55 +0200 Subject: [PATCH 1/2] CONSOLE-5292: Migrate OLM enabled tests to Playwright --- frontend/e2e/pages/details-page.ts | 68 ++++ frontend/e2e/pages/list-page.ts | 33 ++ frontend/e2e/pages/modal-page.ts | 43 ++ frontend/e2e/pages/nav-page.ts | 30 ++ .../e2e/pages/olm/installed-operators-page.ts | 68 ++++ .../e2e/pages/olm/operator-details-page.ts | 106 +++++ frontend/e2e/pages/olm/operator-hub-page.ts | 138 +++++++ frontend/e2e/pages/yaml-editor-page.ts | 32 ++ .../tests/olm/catalog-source-details.spec.ts | 134 +++++++ .../e2e/tests/olm/create-namespace.spec.ts | 99 +++++ .../olm/deprecated-operator-warnings.spec.ts | 375 ++++++++++++++++++ frontend/e2e/tests/olm/descriptors.spec.ts | 213 ++++++++++ .../tests/olm/edit-default-sources.spec.ts | 36 ++ frontend/e2e/tests/olm/mocks/index.ts | 335 ++++++++++++++++ .../e2e/tests/olm/packageserver-tabs.spec.ts | 86 ++++ 15 files changed, 1796 insertions(+) create mode 100644 frontend/e2e/pages/details-page.ts create mode 100644 frontend/e2e/pages/list-page.ts create mode 100644 frontend/e2e/pages/modal-page.ts create mode 100644 frontend/e2e/pages/nav-page.ts create mode 100644 frontend/e2e/pages/olm/installed-operators-page.ts create mode 100644 frontend/e2e/pages/olm/operator-details-page.ts create mode 100644 frontend/e2e/pages/olm/operator-hub-page.ts create mode 100644 frontend/e2e/pages/yaml-editor-page.ts create mode 100644 frontend/e2e/tests/olm/catalog-source-details.spec.ts create mode 100644 frontend/e2e/tests/olm/create-namespace.spec.ts create mode 100644 frontend/e2e/tests/olm/deprecated-operator-warnings.spec.ts create mode 100644 frontend/e2e/tests/olm/descriptors.spec.ts create mode 100644 frontend/e2e/tests/olm/edit-default-sources.spec.ts create mode 100644 frontend/e2e/tests/olm/mocks/index.ts create mode 100644 frontend/e2e/tests/olm/packageserver-tabs.spec.ts diff --git a/frontend/e2e/pages/details-page.ts b/frontend/e2e/pages/details-page.ts new file mode 100644 index 00000000000..72105aaf9f4 --- /dev/null +++ b/frontend/e2e/pages/details-page.ts @@ -0,0 +1,68 @@ +import type { Locator, Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import BasePage from './base-page'; + +export class DetailsPage extends BasePage { + private readonly pageHeading = this.page.locator('[data-test="page-heading"]'); + private readonly resourceTitle = this.page.locator('[data-test-id="resource-title"]'); + private readonly skeletonDetailView = this.page.getByTestId('skeleton-detail-view'); + private readonly actionsMenuButton = this.page.locator('[data-test-id="actions-menu-button"]'); + + constructor(page: Page) { + super(page); + } + + async isLoaded(): Promise { + await expect(this.skeletonDetailView).not.toBeAttached({ timeout: 30_000 }); + await expect(this.resourceTitle).not.toBeEmpty({ timeout: 30_000 }); + } + + async titleShouldContain(title: string): Promise { + await expect(this.pageHeading).toBeAttached({ timeout: 30_000 }); + await expect(this.pageHeading).toContainText(title, { timeout: 30_000 }); + } + + async sectionHeaderShouldExist(heading: string): Promise { + await expect(this.page.locator(`[data-test-section-heading="${heading}"]`)).toBeAttached(); + } + + async selectTab(name: string): Promise { + const tab = this.page.locator(`[data-test-id="horizontal-link-${name}"]`); + await expect(tab).toBeAttached(); + await this.robustClick(tab); + await this.waitForLoadingComplete(); + } + + async clickPageActionFromDropdown(actionID: string): Promise { + await this.robustClick(this.actionsMenuButton); + await this.robustClick(this.page.locator(`[data-test-action="${actionID}"]:not([disabled])`)); + } + + async clickPageActionButton(action: string): Promise { + const actionButton = this.page.locator('[data-test-id="details-actions"]', { + hasText: action, + }); + await this.robustClick(actionButton); + } + + sectionHeading(name: string): Locator { + return this.page.locator(`[data-test-section-heading="${name}"]`); + } + + detailsItemLabel(name: string): Locator { + return this.page.locator(`[data-test-selector="details-item-label__${name}"]`); + } + + detailsItemValue(name: string): Locator { + return this.page.locator(`[data-test-selector="details-item-value__${name}"]`); + } + + horizontalNavTab(tabId: string): Locator { + return this.page.locator(`[data-test-id="horizontal-link-${tabId}"]`); + } + + breadcrumb(index: number): Locator { + return this.page.locator(`[data-test-id="breadcrumb-link-${index}"]`); + } +} diff --git a/frontend/e2e/pages/list-page.ts b/frontend/e2e/pages/list-page.ts new file mode 100644 index 00000000000..2c2704aec3b --- /dev/null +++ b/frontend/e2e/pages/list-page.ts @@ -0,0 +1,33 @@ +import type { Locator, Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import BasePage from './base-page'; + +export class ListPage extends BasePage { + private readonly pageHeading = this.page.locator('[data-test="page-heading"]'); + private readonly nameFilterInput = this.page.getByTestId('name-filter-input'); + + constructor(page: Page) { + super(page); + } + + async titleShouldHaveText(title: string): Promise { + await expect(this.pageHeading).toHaveText(title); + } + + async filterByName(name: string): Promise { + await this.nameFilterInput.fill(name); + } + + resourceRow(name: string): Locator { + return this.page.locator(`[data-test-rows="resource-row"]`, { hasText: name }); + } + + async rowShouldExist(name: string): Promise { + await expect(this.resourceRow(name)).toBeAttached(); + } + + async rowShouldNotExist(name: string): Promise { + await expect(this.resourceRow(name)).not.toBeAttached(); + } +} diff --git a/frontend/e2e/pages/modal-page.ts b/frontend/e2e/pages/modal-page.ts new file mode 100644 index 00000000000..b46fcd441ea --- /dev/null +++ b/frontend/e2e/pages/modal-page.ts @@ -0,0 +1,43 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import BasePage from './base-page'; + +export class ModalPage extends BasePage { + private readonly cancelButton = this.page.locator('[data-test-id="modal-cancel-action"]'); + private readonly submitBtn = this.page.locator('button[type=submit]'); + private readonly modalTitle = this.page.locator('[data-test-id="modal-title"]'); + + constructor(page: Page) { + super(page); + } + + async shouldBeOpened(): Promise { + await this.cancelButton.scrollIntoViewIfNeeded({ timeout: 20_000 }); + await expect(this.cancelButton).toBeVisible(); + } + + async shouldBeClosed(): Promise { + await expect(this.cancelButton).not.toBeAttached(); + } + + async submit(force = false): Promise { + await this.robustClick(this.submitBtn, { force }); + } + + async cancel(force = false): Promise { + await this.robustClick(this.cancelButton, { force }); + } + + async modalTitleShouldContain(title: string): Promise { + await expect(this.modalTitle).toContainText(title); + } + + async submitShouldBeDisabled(): Promise { + await expect(this.submitBtn).toBeDisabled(); + } + + async submitShouldBeEnabled(): Promise { + await expect(this.submitBtn).not.toBeDisabled(); + } +} diff --git a/frontend/e2e/pages/nav-page.ts b/frontend/e2e/pages/nav-page.ts new file mode 100644 index 00000000000..177dbc4c34f --- /dev/null +++ b/frontend/e2e/pages/nav-page.ts @@ -0,0 +1,30 @@ +import type { Page } from '@playwright/test'; + +import BasePage from './base-page'; + +export class NavPage extends BasePage { + private readonly sidebar = this.page.locator('#page-sidebar'); + + constructor(page: Page) { + super(page); + } + + async clickNavLink(path: string[]): Promise { + if (path.length === 2) { + const parentButton = this.sidebar.getByRole('button', { name: path[0], exact: true }); + const isExpanded = + (await parentButton.getAttribute('aria-expanded').catch(() => null)) === 'true'; + if (!isExpanded) { + await this.robustClick(parentButton); + } + const childLink = this.sidebar + .getByRole('region', { name: path[0] }) + .getByRole('link', { name: path[1], exact: true }); + await this.robustClick(childLink); + } else { + const targetButton = this.sidebar.getByRole('button', { name: path[0], exact: true }); + await this.robustClick(targetButton); + } + await this.waitForLoadingComplete(); + } +} diff --git a/frontend/e2e/pages/olm/installed-operators-page.ts b/frontend/e2e/pages/olm/installed-operators-page.ts new file mode 100644 index 00000000000..474b17eae7f --- /dev/null +++ b/frontend/e2e/pages/olm/installed-operators-page.ts @@ -0,0 +1,68 @@ +import type { Locator, Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import BasePage from '../base-page'; + +export const GLOBAL_INSTALLED_NAMESPACE = 'openshift-operators'; + +export class InstalledOperatorsPage extends BasePage { + private readonly nameFilterInput = this.page.getByTestId('name-filter-input'); + + constructor(page: Page) { + super(page); + } + + async navigateTo(namespace: string = GLOBAL_INSTALLED_NAMESPACE): Promise { + await this.goTo(`/k8s/ns/${namespace}/operators.coreos.com~v1alpha1~ClusterServiceVersion`); + } + + async filterByName(name: string): Promise { + await this.nameFilterInput.focus(); + await this.nameFilterInput.clear(); + await this.nameFilterInput.fill(name); + } + + operatorRow(name: string): Locator { + return this.page.locator(`[data-test-operator-row="${name}"]`); + } + + statusText(): Locator { + return this.page.getByTestId('status-text'); + } + + async waitForOperatorSucceeded(name: string, timeoutMs = 720_000): Promise { + await expect(this.operatorRow(name)).toBeAttached({ timeout: 300_000 }); + await expect(this.statusText()).toContainText('Succeeded', { timeout: timeoutMs }); + } + + async clickOperatorRow(name: string): Promise { + await this.robustClick(this.operatorRow(name)); + } + + async navigateToOperatorDetails( + name: string, + namespace: string = GLOBAL_INSTALLED_NAMESPACE, + ): Promise { + await this.navigateTo(namespace); + await this.filterByName(name); + await expect(this.operatorRow(name)).toBeVisible({ timeout: 30_000 }); + await this.robustClick(this.operatorRow(name)); + await expect(this.page).toHaveURL(/ClusterServiceVersion/, { timeout: 30_000 }); + await this.page + .locator('[data-test-id="horizontal-link-Details"]') + .waitFor({ state: 'attached', timeout: 30_000 }); + } + + async operatorShouldNotExist(name: string): Promise { + await expect(this.operatorRow(name)).not.toBeAttached(); + } + + async selectProject(projectName: string): Promise { + const dropdown = this.page.locator('[data-test-id="namespace-bar-dropdown"] button').first(); + await this.robustClick(dropdown); + const searchInput = this.page.locator('[data-test="dropdown-text-filter"]'); + await searchInput.fill(projectName); + const projectOption = this.page.locator(`[id="${projectName}-link"]`); + await this.robustClick(projectOption); + } +} diff --git a/frontend/e2e/pages/olm/operator-details-page.ts b/frontend/e2e/pages/olm/operator-details-page.ts new file mode 100644 index 00000000000..f56322e3b5f --- /dev/null +++ b/frontend/e2e/pages/olm/operator-details-page.ts @@ -0,0 +1,106 @@ +import type { Locator, Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import BasePage from '../base-page'; +import { DetailsPage } from '../details-page'; +import { ModalPage } from '../modal-page'; + +export interface TestOperandProps { + name: string; + group: string; + version: string; + kind: string; + createActionID?: string; + exampleName: string; +} + +export class OperatorDetailsPage extends BasePage { + private readonly detailsPage: DetailsPage; + private readonly modalPage: ModalPage; + + constructor(page: Page) { + super(page); + this.detailsPage = new DetailsPage(page); + this.modalPage = new ModalPage(page); + } + + async isLoaded(): Promise { + await this.page + .locator('[data-test-id="horizontal-link-Details"]') + .waitFor({ state: 'attached', timeout: 30_000 }); + } + + async selectTab(tabName: string): Promise { + const tab = this.page.locator(`[data-test-id="horizontal-link-${tabName}"]`).last(); + await expect(tab).toBeAttached({ timeout: 60_000 }); + await this.robustClick(tab); + await this.waitForLoadingComplete(); + } + + async openUninstallModal(): Promise { + await this.detailsPage.clickPageActionFromDropdown('Uninstall Operator'); + await this.modalPage.shouldBeOpened(); + await this.modalPage.modalTitleShouldContain('Uninstall Operator?'); + await expect(this.page.locator('.loading-skeleton--table')).not.toBeAttached({ + timeout: 120_000, + }); + } + + async checkDeleteAllOperands(): Promise { + await this.robustClick(this.page.getByTestId('delete-all-operands')); + } + + async createOperand(operand: TestOperandProps): Promise { + await this.selectTab(operand.name === 'All instances' ? 'All instances' : operand.name); + await expect( + this.page.locator(`[data-test-operand-link="${operand.exampleName}"]`), + ).not.toBeAttached(); + await this.robustClick(this.page.getByTestId('item-create')); + if (operand.createActionID) { + await this.robustClick(this.page.getByTestId(operand.createActionID)); + } + await expect(this.page).toHaveURL(/~new/); + const nameInput = this.page.locator('#root_metadata_name'); + await expect(nameInput).not.toBeDisabled(); + await nameInput.clear(); + await nameInput.fill(operand.exampleName); + await this.robustClick(this.page.locator('button[type=submit]')); + await expect(this.page).not.toHaveURL(/~new/, { timeout: 60_000 }); + } + + async deleteOperand(operand: TestOperandProps): Promise { + await this.selectTab(operand.name === 'All instances' ? 'All instances' : operand.name); + await this.robustClick(this.page.locator(`[data-test-operand-link="${operand.exampleName}"]`)); + await this.detailsPage.clickPageActionFromDropdown(`Delete ${operand.kind}`); + await this.modalPage.shouldBeOpened(); + await this.modalPage.submit(); + await this.modalPage.shouldBeClosed(); + } + + async operandShouldExist(operand: TestOperandProps): Promise { + await this.selectTab(operand.name === 'All instances' ? 'All instances' : operand.name); + await expect(this.page.getByTestId(operand.exampleName)).toBeAttached(); + } + + async operandShouldNotExist(operand: TestOperandProps): Promise { + await this.selectTab(operand.name === 'All instances' ? 'All instances' : operand.name); + await expect(this.page.getByTestId(operand.exampleName)).not.toBeAttached(); + } + + async uninstall(deleteAllOperands = false): Promise { + await this.openUninstallModal(); + if (deleteAllOperands) { + await this.checkDeleteAllOperands(); + } + await this.modalPage.submit(true); + await this.modalPage.shouldBeClosed(); + } + + operandLink(name: string): Locator { + return this.page.locator(`[data-test-operand-link="${name}"]`); + } + + sectionHeading(name: string): Locator { + return this.page.locator(`[data-test-section-heading="${name}"]`); + } +} diff --git a/frontend/e2e/pages/olm/operator-hub-page.ts b/frontend/e2e/pages/olm/operator-hub-page.ts new file mode 100644 index 00000000000..6fc94c8f05e --- /dev/null +++ b/frontend/e2e/pages/olm/operator-hub-page.ts @@ -0,0 +1,138 @@ +import type { Locator, Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import BasePage from '../base-page'; + +export class OperatorHubPage extends BasePage { + private readonly operatorTab = this.page.getByTestId('tab operator'); + private readonly searchCatalog = this.page.getByTestId('search-catalog').locator('input'); + private readonly installCta = this.page.getByTestId('catalog-details-modal-cta'); + private readonly channelSelectToggle = this.page.getByTestId('operator-channel-select-toggle'); + private readonly versionSelectToggle = 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 namespaceDropdown = this.page.getByTestId('dropdown-selectbox'); + private readonly namespaceSearchInput = this.page + .getByTestId('console-select-search-input') + .locator('input'); + private readonly installOperatorButton = this.page.getByTestId('install-operator'); + private readonly viewInstalledButton = this.page.getByTestId('view-installed-operators-btn'); + + constructor(page: Page) { + super(page); + } + + async navigateToCatalog(namespace: string): Promise { + await this.goTo(`/catalog/ns/${namespace}`); + } + + async searchOperator(name: string): Promise { + await this.robustClick(this.operatorTab); + await this.searchCatalog.fill(name); + } + + async clickOperatorTile(testID: string): Promise { + await this.robustClick(this.page.getByTestId(testID)); + } + + async clickInstallButton(): Promise { + await expect(this.installCta).toBeVisible(); + await expect(this.installCta).toHaveAttribute('href'); + await this.robustClick(this.installCta); + } + + async verifyInstallFormLoaded(): Promise { + await expect(this.channelSelectToggle).toBeAttached(); + await expect(this.versionSelectToggle).toBeAttached(); + } + + async selectInstallMode( + mode: 'global' | 'namespace', + namespace?: string, + useRecommended = false, + ): Promise { + if (mode === 'namespace') { + await this.specificNamespaceRadio.waitFor({ state: 'visible' }); + await this.specificNamespaceRadio.check(); + if (useRecommended) { + await this.page.getByTestId('Operator recommended Namespace:-radio-input').check(); + } else { + const selectNsRadio = this.page.getByTestId('Select a Namespace-radio-input'); + if ((await selectNsRadio.count()) > 0) { + await selectNsRadio.check(); + } + if (namespace) { + await this.robustClick(this.namespaceDropdown); + await this.namespaceSearchInput.fill(namespace); + await this.robustClick( + this.page.locator(`[data-test-dropdown-menu="${namespace}-Project"]`), + ); + await expect(this.namespaceDropdown).toContainText(namespace); + } + } + } else { + await expect(this.allNamespacesRadio).toBeChecked(); + } + } + + async submitInstall(): Promise { + await this.robustClick(this.installOperatorButton); + } + + async verifyInstallationStarted(): Promise { + await expect(this.viewInstalledButton).toContainText('View installed Operators in Namespace'); + } + + async clickViewInstalledOperators(): Promise { + await this.robustClick(this.viewInstalledButton); + } + + async installOperator( + operatorName: string, + operatorCardTestID: string, + namespace: string, + installToNamespace = 'openshift-operators', + useRecommended = false, + ): Promise { + await this.navigateToCatalog(namespace); + await this.searchOperator(operatorName); + await this.clickOperatorTile(operatorCardTestID); + await this.clickInstallButton(); + await this.verifyInstallFormLoaded(); + const mode = installToNamespace === 'openshift-operators' ? 'global' : 'namespace'; + await this.selectInstallMode(mode, installToNamespace, useRecommended); + await this.submitInstall(); + await this.verifyInstallationStarted(); + await this.clickViewInstalledOperators(); + } + + async openCreateNamespaceModal(): Promise { + await this.robustClick(this.namespaceDropdown); + const createOption = this.page.locator('[data-test-dropdown-menu^="Create_"]'); + await this.robustClick(createOption); + } + + catalogTile(testID: string): Locator { + return this.page.getByTestId(testID); + } + + deprecatedBadge(): Locator { + return this.page.getByTestId('deprecated-operator-badge'); + } + + deprecationWarning(type: 'package' | 'channel' | 'version'): Locator { + return this.page.getByTestId(`deprecated-${type}-warning`); + } + + channelOption(channel: string): Locator { + return this.page.locator(`[data-test="operator-channel-${channel}"]`); + } + + versionOption(version: string): Locator { + return this.page.locator(`[data-test="operator-version-${version}"]`); + } +} diff --git a/frontend/e2e/pages/yaml-editor-page.ts b/frontend/e2e/pages/yaml-editor-page.ts new file mode 100644 index 00000000000..83a1b20ee75 --- /dev/null +++ b/frontend/e2e/pages/yaml-editor-page.ts @@ -0,0 +1,32 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import BasePage from './base-page'; + +export class YamlEditorPage extends BasePage { + private readonly codeEditor = this.page.getByTestId('code-editor'); + + constructor(page: Page) { + super(page); + } + + async isLoaded(): Promise { + await expect(this.codeEditor).toBeAttached(); + } + + async getEditorContent(): Promise { + await this.isLoaded(); + return this.page.evaluate(() => { + const models = (window as any).monaco?.editor?.getModels?.(); + return models?.[0]?.getValue() ?? ''; + }); + } + + async setEditorContent(text: string): Promise { + await this.isLoaded(); + await this.page.evaluate((content) => { + const models = (window as any).monaco?.editor?.getModels?.(); + models?.[0]?.setValue(content); + }, text); + } +} 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..b20aac49585 --- /dev/null +++ b/frontend/e2e/tests/olm/catalog-source-details.spec.ts @@ -0,0 +1,134 @@ +import { test, expect } from '../../fixtures'; +import { DetailsPage } from '../../pages/details-page'; +import { ModalPage } from '../../pages/modal-page'; +import { createTestCatalogSource } from './mocks'; + +const managedCatalogSource = { + name: 'redhat-operators', + displayName: 'Red Hat Operators', +}; + +test.describe('CatalogSource details page', { tag: ['@admin'] }, () => { + const testNs = `olm-catsrc-${Date.now()}`; + let catalogSourceName: string; + + test.beforeAll(async ({ k8sClient }) => { + await k8sClient.createNamespace(testNs); + const catalogSource = createTestCatalogSource(testNs); + catalogSourceName = catalogSource.metadata.name; + await k8sClient.createCustomResource( + 'operators.coreos.com', + 'v1alpha1', + testNs, + 'catalogsources', + catalogSource, + ); + }); + + test.afterAll(async ({ k8sClient }) => { + await k8sClient.deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + testNs, + 'catalogsources', + catalogSourceName, + ); + await k8sClient.deleteNamespace(testNs); + }); + + async function navigateToCatalogSourcesList(page: import('@playwright/test').Page) { + const detailsPage = new DetailsPage(page); + + await page.goto('/settings/cluster/globalconfig'); + await page + .getByTestId('loading-indicator') + .waitFor({ state: 'hidden' }) + .catch(() => {}); + const operatorHubLink = page.locator('[data-test-id="OperatorHub"]'); + await operatorHubLink.waitFor({ timeout: 120_000 }); + await operatorHubLink.scrollIntoViewIfNeeded(); + await operatorHubLink.click(); + await detailsPage.sectionHeaderShouldExist('OperatorHub details'); + await page.locator('[data-test-id="horizontal-link-Sources"]').click(); + } + + test(`renders details about the ${managedCatalogSource.name} catalog source`, async ({ + page, + }) => { + const detailsPage = new DetailsPage(page); + await navigateToCatalogSourcesList(page); + await page.locator(`[data-test-id="${managedCatalogSource.name}"]`).click(); + + await detailsPage.sectionHeaderShouldExist('CatalogSource details'); + + await expect( + page.locator('[data-test-selector="details-item-value__Status"]'), + ).toHaveText('READY', { timeout: 300_000 }); + + await expect(page.locator('[data-test-selector="details-item-label__Name"]')).toBeVisible(); + await expect(page.locator('[data-test-selector="details-item-value__Name"]')).toHaveText( + managedCatalogSource.name, + ); + + await expect(page.locator('[data-test-selector="details-item-label__Status"]')).toBeVisible(); + + await expect( + page.locator('[data-test-selector="details-item-label__Display name"]'), + ).toBeVisible(); + await expect( + page.locator('[data-test-selector="details-item-value__Display name"]'), + ).toHaveText(managedCatalogSource.displayName); + + const pollIntervalLabel = page.getByTestId('Registry poll interval'); + await pollIntervalLabel.scrollIntoViewIfNeeded(); + await expect(pollIntervalLabel).toBeVisible(); + const pollIntervalValue = page.locator( + '[data-test-selector="details-item-value__Registry poll interval"]', + ); + await pollIntervalValue.scrollIntoViewIfNeeded(); + await expect(pollIntervalValue).toBeVisible(); + + const numOperatorsLabel = page.locator( + '[data-test-selector="details-item-label__Number of Operators"]', + ); + await numOperatorsLabel.scrollIntoViewIfNeeded(); + await expect(numOperatorsLabel).toBeVisible(); + const numOperatorsValue = page.locator( + '[data-test-selector="details-item-value__Number of Operators"]', + ); + await numOperatorsValue.scrollIntoViewIfNeeded(); + await expect(numOperatorsValue).toBeVisible(); + }); + + test(`lists package manifests for ${managedCatalogSource.name} under Operators tab`, async ({ + page, + }) => { + const detailsPage = new DetailsPage(page); + await navigateToCatalogSourcesList(page); + await page.locator(`[data-test-id="${managedCatalogSource.name}"]`).click(); + + await detailsPage.sectionHeaderShouldExist('CatalogSource details'); + + await page.locator('[data-test-id="horizontal-link-Operators"]').click(); + await expect(page.getByTestId('PackageManifestTable')).toBeAttached(); + }); + + test('allows modifying registry poll interval on test catalog source', async ({ page }) => { + const modalPage = new ModalPage(page); + await navigateToCatalogSourcesList(page); + await page.locator(`[data-test-id="${catalogSourceName}"]`).click(); + + await page.getByTestId('Registry poll interval-details-item__edit-button').click(); + await expect(page.getByTestId('registry-poll-interval-modal-title')).toContainText( + 'Edit registry poll interval', + ); + await page.getByTestId('registry-poll-interval-dropdown').click(); + await page.locator('[data-test-dropdown-menu="30m"]').waitFor({ state: 'visible' }); + await page.locator('[data-test-dropdown-menu="30m"]').click(); + await modalPage.submit(); + + await expect( + page.locator('[data-test-selector="details-item-value__Registry poll interval"]'), + ).toHaveText('30m'); + }); +}); diff --git a/frontend/e2e/tests/olm/create-namespace.spec.ts b/frontend/e2e/tests/olm/create-namespace.spec.ts new file mode 100644 index 00000000000..fd5815930ca --- /dev/null +++ b/frontend/e2e/tests/olm/create-namespace.spec.ts @@ -0,0 +1,99 @@ +import type KubernetesClient from '../../clients/kubernetes-client'; +import { test, expect } from '../../fixtures'; +import { ModalPage } from '../../pages/modal-page'; +import { OperatorHubPage } from '../../pages/olm/operator-hub-page'; + +const OPERATOR_SUBSCRIPTION = '3scale-operator'; + +async function cleanupOperator(k8sClient: KubernetesClient, namespace: string): Promise { + await k8sClient + .deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + namespace, + 'subscriptions', + OPERATOR_SUBSCRIPTION, + ) + .catch(() => {}); + + const csvs = await k8sClient.listCustomResources( + 'operators.coreos.com', + 'v1alpha1', + namespace, + 'clusterserviceversions', + ); + for (const csv of csvs) { + const name = (csv as any)?.metadata?.name; + if (name?.startsWith('3scale-operator')) { + await k8sClient + .deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + namespace, + 'clusterserviceversions', + name, + ) + .catch(() => {}); + } + } +} + +test.describe('Create namespace from operator install', { tag: ['@admin'] }, () => { + const testNs = `olm-create-ns-${Date.now()}`; + const nsName = `${testNs}-ns`; + + test.beforeAll(async ({ k8sClient }) => { + await cleanupOperator(k8sClient, 'openshift-operators'); + }); + + test.afterAll(async ({ k8sClient }) => { + await cleanupOperator(k8sClient, nsName); + await cleanupOperator(k8sClient, 'openshift-operators'); + await k8sClient.deleteNamespace(nsName).catch(() => {}); + await k8sClient.deleteNamespace(testNs).catch(() => {}); + }); + + test('creates namespace from operator install page', async ({ page, k8sClient, cleanup }) => { + await k8sClient.createNamespace(testNs); + cleanup.trackNamespace(testNs); + + const operatorHubPage = new OperatorHubPage(page); + const modalPage = new ModalPage(page); + + await test.step('Navigate to OperatorHub and select 3scale operator', async () => { + await operatorHubPage.navigateToCatalog(testNs); + await operatorHubPage.searchOperator('Red Hat Integration - 3scale'); + await expect(page).toHaveURL(/keyword/); + await operatorHubPage.clickOperatorTile('operator-Red Hat Integration - 3scale'); + await operatorHubPage.clickInstallButton(); + }); + + await test.step('Verify install form and select single namespace mode', async () => { + await operatorHubPage.verifyInstallFormLoaded(); + const specificNsRadio = page.getByTestId('A specific namespace on the cluster-radio-input'); + await specificNsRadio.waitFor({ state: 'visible' }); + await specificNsRadio.check(); + const selectNsRadio = page.getByTestId('Select a Namespace-radio-input'); + if ((await selectNsRadio.count()) > 0) { + await selectNsRadio.check(); + } + }); + + await test.step('Open Create Namespace modal and create namespace', async () => { + await operatorHubPage.openCreateNamespaceModal(); + await modalPage.shouldBeOpened(); + await page.getByTestId('input-name').fill(nsName); + await modalPage.submit(); + await modalPage.shouldBeClosed(); + cleanup.trackNamespace(nsName); + }); + + await test.step('Verify namespace is selected and install operator', async () => { + await operatorHubPage.selectInstallMode('namespace', nsName); + await operatorHubPage.submitInstall(); + await expect(page.getByTestId('view-installed-operators-btn')).toContainText( + `View installed Operators in Namespace ${nsName}`, + ); + }); + }); +}); 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..6c2e9602cc9 --- /dev/null +++ b/frontend/e2e/tests/olm/deprecated-operator-warnings.spec.ts @@ -0,0 +1,375 @@ +import { test, expect } from '../../fixtures'; +import { InstalledOperatorsPage } from '../../pages/olm/installed-operators-page'; +import { testDeprecatedCatalogSource, testDeprecatedSubscription } from './mocks'; + +const TIMEOUT = 300_000; +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_BADGE_ID = 'deprecated-operator-warning-badge'; +const DEPRECATED_PACKAGE_ID = 'deprecated-operator-warning-package'; +const DEPRECATED_CHANNEL_ID = 'deprecated-operator-warning-channel'; +const DEPRECATED_VERSION_ID = 'deprecated-operator-warning-version'; + +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; + +test.describe('Deprecated operator warnings', { tag: ['@admin'] }, () => { + const testNs = `olm-deprecated-${Date.now()}`; + + test.beforeAll(async ({ k8sClient }) => { + await k8sClient.createNamespace(testNs); + // Clean up any existing resources from previous failed runs + await k8sClient + .deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'subscriptions', + subscriptionName, + ) + .catch(() => {}); + await k8sClient + .deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'clusterserviceversions', + csvName, + ) + .catch(() => {}); + await k8sClient + .deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + catalogSourceNamespace, + 'catalogsources', + catalogSourceName, + ) + .catch(() => {}); + + await k8sClient.createCustomResource( + 'operators.coreos.com', + 'v1alpha1', + catalogSourceNamespace, + 'catalogsources', + testDeprecatedCatalogSource, + ); + + // Wait for CatalogSource to become READY + const ready = await k8sClient.waitForCustomResourceCondition( + 'operators.coreos.com', + 'v1alpha1', + catalogSourceNamespace, + 'catalogsources', + catalogSourceName, + (cs: any) => cs?.status?.connectionState?.lastObservedState === 'READY', + TIMEOUT, + ); + if (!ready) { + throw new Error(`CatalogSource ${catalogSourceName} did not become READY within timeout`); + } + }); + + test.afterAll(async ({ k8sClient }) => { + await k8sClient + .deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'subscriptions', + subscriptionName, + ) + .catch(() => {}); + await k8sClient + .deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'clusterserviceversions', + csvName, + ) + .catch(() => {}); + + // Delete InstallPlans related to the operator + const installPlans = await k8sClient.listCustomResources( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'installplans', + ); + for (const ip of installPlans) { + const ipName = (ip as any)?.metadata?.name; + const csvNames: string[] = (ip as any)?.spec?.clusterServiceVersionNames ?? []; + if (ipName && csvNames.includes(csvName)) { + await k8sClient + .deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'installplans', + ipName, + ) + .catch(() => {}); + } + } + + await k8sClient + .deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + catalogSourceNamespace, + 'catalogsources', + catalogSourceName, + ) + .catch(() => {}); + + await k8sClient.deleteNamespace(testNs); + }); + + test('verify deprecated Operator warning badge on the Operator tile', async ({ page }) => { + await test.step('Verify CatalogSource is READY', async () => { + await page.goto( + `/k8s/ns/${catalogSourceNamespace}/operators.coreos.com~v1alpha1~CatalogSource/${catalogSourceName}`, + ); + await expect( + page.locator('[data-test-selector="details-item-value__Status"]'), + ).toHaveText('READY', { timeout: TIMEOUT }); + }); + + await test.step('Verify deprecated badge on operator tile', async () => { + await page.goto(`/catalog/ns/${testNs}`); + await page.getByTestId('tab operator').click(); + await page.getByTestId('source-community-operators-for-testing-deprecation').click(); + await page.getByTestId('search-catalog').locator('input').fill(testOperatorName); + await expect(page.locator('.co-catalog-tile')).toHaveCount(1, { + timeout: TIMEOUT, + }); + await expect(page.getByTestId('Deprecated-badge')).toContainText(deprecatedBadge); + }); + }); + + test('verify deprecated Operator warnings in the Operator details panel', async ({ page }) => { + await page.goto( + `/catalog/ns/${testNs}?catalogType=operator&keyword=kia&selectedId=kiali-test-community-operator-deprecation-openshift-marketplace&channel=stable&version=1.83.0`, + ); + const detailsPanel = page.getByRole('dialog'); + await expect(detailsPanel.getByTestId('Deprecated-badge')).toContainText(deprecatedBadge); + await expect(detailsPanel.getByTestId(DEPRECATED_PACKAGE_ID)).toContainText( + deprecatedPackageMessage, + ); + }); + + test('verify deprecated channel warnings in the Operator details panel', async ({ page }) => { + await page.goto( + `/catalog/ns/${testNs}?catalogType=operator&keyword=kia&selectedId=kiali-test-community-operator-deprecation-openshift-marketplace&channel=stable&version=1.83.0`, + ); + + await test.step('Verify channel deprecation warnings do not exist initially', async () => { + await expect( + page.getByTestId(DEPRECATED_PACKAGE_ID).locator(`text=${deprecatedChannelMessage}`), + ).not.toBeAttached(); + await expect(page.getByTestId('deprecated-operator-warning-channel-icon')).not.toBeAttached(); + }); + + await test.step('Open channel select and verify deprecation icon', async () => { + await page.getByTestId('operator-channel-select-toggle').click({ force: true }); + await expect(page.getByTestId('deprecated-operator-warning-channel-icon')).toBeAttached(); + }); + + await test.step('Select deprecated channel and verify warning', async () => { + await page.locator('[data-test="channel-option-alpha"] > button').click({ force: true }); + await expect(page.getByTestId(DEPRECATED_CHANNEL_ID)).toContainText(deprecatedChannelMessage); + }); + }); + + test('verify deprecated version warnings in the Operator details panel', async ({ page }) => { + await page.goto( + `/catalog/ns/${testNs}?catalogType=operator&keyword=kia&selectedId=kiali-test-community-operator-deprecation-openshift-marketplace&channel=stable&version=1.83.0`, + ); + + await test.step('Verify version deprecation warnings do not exist initially', async () => { + await expect( + page.getByTestId(DEPRECATED_VERSION_ID).locator(`text=${deprecatedVersionMessage}`), + ).not.toBeAttached(); + await expect(page.getByTestId('deprecated-operator-warning-version-icon')).not.toBeAttached(); + }); + + await test.step('Open version select and verify deprecation icon', async () => { + await page.getByTestId('operator-version-select-toggle').click({ force: true }); + await expect(page.getByTestId('deprecated-operator-warning-version-icon')).toBeAttached(); + }); + + await test.step('Select deprecated version and verify warning', async () => { + await page + .locator('[data-test="version-option-kiali-operator.v1.68.0"] > button') + .click({ force: true }); + await expect(page.getByTestId(DEPRECATED_VERSION_ID)).toContainText(deprecatedVersionMessage); + }); + }); + + test('verify deprecated Operator warnings on Install Operator details page', async ({ page }) => { + await page.goto( + '/operatorhub/subscribe?pkg=kiali&catalog=test-community-operator-deprecation&catalogNamespace=openshift-marketplace&targetNamespace=undefined&channel=alpha&version=1.68.0', + ); + + await expect(page.getByTestId(DEPRECATED_BADGE_ID)).toContainText(deprecatedBadge); + await expect(page.getByTestId(DEPRECATED_PACKAGE_ID)).toContainText(deprecatedPackageMessage); + await expect(page.getByTestId(DEPRECATED_CHANNEL_ID)).toContainText(deprecatedChannelMessage); + await expect(page.getByTestId(DEPRECATED_VERSION_ID)).toContainText(deprecatedVersionMessage); + }); + + function hasPackageDeprecated(sub: any): boolean { + const conditions: any[] = sub?.status?.conditions ?? []; + return conditions.some((c) => c.type === 'PackageDeprecated'); + } + + test.describe('Installed Operator deprecation warnings', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async ({ k8sClient }) => { + // Install operator via API + await k8sClient.createCustomResource( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'subscriptions', + testDeprecatedSubscription, + ); + + // Wait for InstallPlan to be created + const hasInstallPlan = await k8sClient.waitForCustomResourceCondition( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'subscriptions', + subscriptionName, + (sub: any) => !!sub?.status?.installPlanRef?.name, + 120_000, + ); + if (!hasInstallPlan) { + throw new Error('InstallPlan was not created within timeout'); + } + + // Find and approve the InstallPlan + const installPlans = await k8sClient.listCustomResources( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'installplans', + ); + for (const ip of installPlans) { + const csvNames: string[] = (ip as any)?.spec?.clusterServiceVersionNames ?? []; + if (csvNames.includes(csvName)) { + const ipName = (ip as any)?.metadata?.name; + await k8sClient.patchCustomResource( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'installplans', + ipName, + { spec: { approved: true } }, + ); + break; + } + } + + // Wait for CSV to succeed + const csvReady = await k8sClient.waitForCustomResourceCondition( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'clusterserviceversions', + csvName, + (csv: any) => csv?.status?.phase === 'Succeeded', + TIMEOUT, + ); + if (!csvReady) { + throw new Error(`CSV ${csvName} did not reach Succeeded phase within timeout`); + } + + // Wait for PackageDeprecated condition on subscription + await k8sClient.waitForCustomResourceCondition( + 'operators.coreos.com', + 'v1alpha1', + subscriptionNamespace, + 'subscriptions', + subscriptionName, + hasPackageDeprecated, + 180_000, + ); + }); + + test('displays deprecated badge on Installed Operators list page', async ({ page }) => { + const installedPage = new InstalledOperatorsPage(page); + await installedPage.navigateTo(subscriptionNamespace); + await installedPage.filterByName(testOperator.name); + await expect(installedPage.operatorRow(testOperator.name)).toBeAttached(); + await expect(page.getByTestId(DEPRECATED_BADGE_ID)).toContainText(deprecatedBadge, { + timeout: TIMEOUT, + }); + }); + + test('displays deprecation warnings on CSV details page', async ({ page }) => { + await page.goto( + `/k8s/ns/${subscriptionNamespace}/operators.coreos.com~v1alpha1~ClusterServiceVersion/${csvName}`, + ); + await page + .locator('[data-test-id="horizontal-link-Details"]') + .waitFor({ state: 'attached', timeout: 60_000 }); + + await expect(page.getByTestId(DEPRECATED_BADGE_ID)).toContainText(deprecatedBadge, { + timeout: TIMEOUT, + }); + await expect(page.getByTestId(DEPRECATED_PACKAGE_ID)).toContainText( + deprecatedPackageMessage, + { timeout: TIMEOUT }, + ); + await expect(page.getByTestId(DEPRECATED_CHANNEL_ID)).toContainText( + deprecatedChannelMessage, + { timeout: TIMEOUT }, + ); + await expect(page.getByTestId(DEPRECATED_VERSION_ID)).toContainText( + deprecatedVersionMessage, + { timeout: TIMEOUT }, + ); + }); + + test('displays deprecation warnings on CSV subscription tab', async ({ page }) => { + await page.goto( + `/k8s/ns/${subscriptionNamespace}/operators.coreos.com~v1alpha1~ClusterServiceVersion/${csvName}/subscription`, + ); + await page + .locator('[data-test-id="horizontal-link-Subscription"]') + .waitFor({ state: 'attached', timeout: 60_000 }); + + await expect(page.getByTestId(DEPRECATED_PACKAGE_ID)).toContainText( + deprecatedPackageMessage, + { timeout: TIMEOUT }, + ); + await expect(page.getByTestId(DEPRECATED_CHANNEL_ID)).toContainText( + deprecatedChannelMessage, + { timeout: TIMEOUT }, + ); + await expect(page.getByTestId(DEPRECATED_VERSION_ID)).toContainText( + deprecatedVersionMessage, + { timeout: TIMEOUT }, + ); + await expect( + page.getByTestId('deprecated-operator-warning-subscription-update-icon'), + ).toBeAttached({ timeout: TIMEOUT }); + + await page.getByTestId('subscription-channel-update-button').click(); + await expect(page.locator('.pf-v6-c-modal-box')).toBeVisible({ timeout: 30_000 }); + await expect( + page.locator('.pf-v6-c-modal-box').getByTestId('kiali-operator.v1.83.0').first(), + ).toBeAttached(); + }); + }); +}); diff --git a/frontend/e2e/tests/olm/descriptors.spec.ts b/frontend/e2e/tests/olm/descriptors.spec.ts new file mode 100644 index 00000000000..0ecc5450f93 --- /dev/null +++ b/frontend/e2e/tests/olm/descriptors.spec.ts @@ -0,0 +1,213 @@ +import { test, expect } from '../../fixtures'; +import { createTestCR, createTestCRD, createTestCSV } from './mocks'; + +test.describe('OLM descriptor components', { tag: ['@admin'] }, () => { + const testNs = `olm-desc-${Date.now()}`; + const crd = createTestCRD(testNs); + const csv = createTestCSV(testNs); + const cr = createTestCR(testNs); + const { group } = crd.spec; + const version = crd.spec.versions[0].name; + const kind = crd.spec.names.kind; + const baseUrl = `/k8s/ns/${testNs}/operators.coreos.com~v1alpha1~ClusterServiceVersion/${csv.metadata.name}/${group}~${version}~${kind}`; + + test.beforeAll(async ({ k8sClient }) => { + await k8sClient.createNamespace(testNs); + await k8sClient.createClusterCustomResource( + 'apiextensions.k8s.io', + 'v1', + 'customresourcedefinitions', + crd, + ); + await k8sClient.createCustomResource( + 'operators.coreos.com', + 'v1alpha1', + testNs, + 'clusterserviceversions', + csv, + ); + }); + + test.afterAll(async ({ k8sClient }) => { + await k8sClient + .deleteCustomResource( + crd.spec.group, + version, + testNs, + crd.spec.names.plural, + cr.metadata.name, + ) + .catch(() => {}); + await k8sClient.deleteClusterCustomResource( + 'apiextensions.k8s.io', + 'v1', + 'customresourcedefinitions', + crd.metadata.name, + ); + await k8sClient.deleteCustomResource( + 'operators.coreos.com', + 'v1alpha1', + testNs, + 'clusterserviceversions', + csv.metadata.name, + ); + await k8sClient.deleteNamespace(testNs); + }); + + test('displays list and detail views of an operand', async ({ page, k8sClient }) => { + await test.step('Create test CR via API', async () => { + await k8sClient.createCustomResource( + crd.spec.group, + version, + testNs, + crd.spec.names.plural, + cr, + ); + }); + + await test.step('Verify operand appears in list view', async () => { + await page.goto(baseUrl); + await expect(page.locator(`[data-test-operand-link="${cr.metadata.name}"]`)).toBeAttached(); + }); + + await test.step('Verify operand details page', async () => { + await page.goto(`${baseUrl}/${cr.metadata.name}`); + await expect(page.locator('[data-test-id="resource-title"]')).toHaveText(cr.metadata.name); + }); + + await test.step('Verify spec descriptors are displayed', async () => { + const specDescriptors = csv.spec.customresourcedefinitions.owned[0].specDescriptors; + for (const descriptor of specDescriptors) { + const label = page + .locator(`[data-test-selector="details-item-label__${descriptor.displayName}"]`) + .first(); + if (descriptor.path === 'hidden') { + await expect(label).not.toBeAttached(); + } else { + await expect(label).toBeAttached(); + } + } + }); + + await test.step('Verify status descriptors are displayed', async () => { + const statusDescriptors = csv.spec.customresourcedefinitions.owned[0].statusDescriptors.filter( + (d) => d.path !== 'conditions', + ); + for (const descriptor of statusDescriptors) { + const label = page + .locator(`[data-test-selector="details-item-label__${descriptor.displayName}"]`) + .first(); + if (descriptor.path === 'hidden') { + await expect(label).not.toBeAttached(); + } else { + await expect(label).toBeAttached(); + } + } + }); + + await test.step('Clean up test CR', async () => { + await k8sClient + .deleteCustomResource( + crd.spec.group, + version, + testNs, + crd.spec.names.plural, + cr.metadata.name, + ) + .catch(() => {}); + }); + }); + + test('creates an operand using the form', async ({ page }) => { + 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 }, + ]; + + function getNestedValue(obj: any, path: string): any { + return path.split('.').reduce((acc, key) => acc?.[key], obj); + } + + await test.step('Navigate to create operand form', async () => { + await page.goto(baseUrl); + await page.getByTestId('item-create').click({ force: true }); + await expect(page.locator('[data-test="page-heading"] h1')).toHaveText('Create App'); + const formRadio = page.getByRole('radio', { name: 'Form view' }); + await formRadio.waitFor(); + await formRadio.click(); + }); + + await test.step('Verify atomic form fields', async () => { + for (const { label, id, path } of atomicFields) { + await expect(page.locator(`#${id}_field`)).toBeAttached(); + await expect(page.locator(`[for=${id}]`)).toHaveText(label); + await expect(page.locator(`#${id}`)).toHaveValue(String(getNestedValue(cr, path))); + } + }); + + await test.step('Verify select field', async () => { + await expect(page.locator(`#${SELECT_FIELD_ID}_field`)).toBeAttached(); + await expect(page.locator(`[for=${SELECT_FIELD_ID}]`)).toHaveText('Select'); + await expect(page.locator(`#${SELECT_FIELD_ID}`)).toHaveText(String(cr.spec.select)); + }); + + await test.step('Verify labels field', async () => { + await expect(page.locator(`#${LABELS_FIELD_ID}_field`)).toBeAttached(); + await expect(page.locator(`[for=${LABELS_FIELD_ID}]`)).toHaveText('Labels'); + await expect(page.locator(`#${LABELS_FIELD_ID}_field .tag-item-content`)).toHaveText( + `automatedTestName=${testNs}`, + ); + }); + + await test.step('Verify field group', async () => { + await expect(page.locator(`#${FIELD_GROUP_ID}_field-group`)).toBeAttached(); + await page.locator(`#${FIELD_GROUP_ID}_accordion-toggle`).click(); + await expect(page.locator(`[for="${FIELD_GROUP_ID}_itemOne"]`)).toHaveText('itemOne'); + await expect(page.locator(`#${FIELD_GROUP_ID}_itemOne`)).toHaveValue( + cr.spec.fieldGroup.itemOne, + ); + await expect(page.locator(`[for="${FIELD_GROUP_ID}_itemTwo"]`)).toHaveText('itemTwo'); + await expect(page.locator(`#${FIELD_GROUP_ID}_itemTwo`)).toHaveValue( + String(cr.spec.fieldGroup.itemTwo), + ); + }); + + await test.step('Verify array field group', async () => { + await expect(page.locator(`#${ARRAY_FIELD_GROUP_ID}_field-group`)).toBeAttached(); + await page.locator(`#${ARRAY_FIELD_GROUP_ID}_accordion-toggle`).click(); + await expect(page.locator(`[for="${ARRAY_FIELD_GROUP_ID}_0_itemOne"]`)).toHaveText( + 'Item One', + ); + await expect(page.locator(`#${ARRAY_FIELD_GROUP_ID}_0_itemOne`)).toHaveValue( + cr.spec.arrayFieldGroup[0].itemOne, + ); + await expect(page.locator(`[for="${ARRAY_FIELD_GROUP_ID}_0_itemTwo"]`)).toHaveText( + 'Item Two', + ); + await expect(page.locator(`#${ARRAY_FIELD_GROUP_ID}_0_itemTwo`)).toHaveValue( + String(cr.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 page.locator('#root_metadata_name').clear(); + await page.locator('#root_metadata_name').fill(cr.metadata.name); + await page.getByTestId('create-dynamic-form').click(); + await page.locator(`[data-test-operand-link="${cr.metadata.name}"]`).click({ force: true }); + await expect(page.getByTestId('operand-details__section--info').first()).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..59e81c9b5a7 --- /dev/null +++ b/frontend/e2e/tests/olm/edit-default-sources.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from '../../fixtures'; +import { DetailsPage } from '../../pages/details-page'; +import { ModalPage } from '../../pages/modal-page'; + +test.describe('Edit default catalog sources', { tag: ['@admin'] }, () => { + test('disables and re-enables default catalog sources from OperatorHub details page', async ({ + page, + }) => { + const detailsPage = new DetailsPage(page); + const modalPage = new ModalPage(page); + const defaultSourceToBeToggled = 'redhat-operators'; + + await test.step('Navigate to OperatorHub configuration page', async () => { + await page.goto('/settings/cluster'); + await page.locator('[data-test-id="horizontal-link-Configuration"]').click(); + await page.locator('[data-test-id="OperatorHub"]').click(); + await detailsPage.sectionHeaderShouldExist('OperatorHub details'); + }); + + await test.step('Disable the default source via modal', async () => { + await page.getByTestId('Default sources-details-item__edit-button').click(); + await modalPage.modalTitleShouldContain('Edit default sources'); + await page.getByTestId(`${defaultSourceToBeToggled}__checkbox`).click(); + await modalPage.submit(); + await expect(page.getByTestId(`status_${defaultSourceToBeToggled}`)).toHaveText('Disabled'); + }); + + await test.step('Re-enable the default source via modal', async () => { + await page.getByTestId('Default sources-details-item__edit-button').click(); + await modalPage.modalTitleShouldContain('Edit default sources'); + await page.getByTestId(`${defaultSourceToBeToggled}__checkbox`).click(); + await modalPage.submit(); + await expect(page.getByTestId(`status_${defaultSourceToBeToggled}`)).toHaveText('Enabled'); + }); + }); +}); diff --git a/frontend/e2e/tests/olm/mocks/index.ts b/frontend/e2e/tests/olm/mocks/index.ts new file mode 100644 index 00000000000..331df27b161 --- /dev/null +++ b/frontend/e2e/tests/olm/mocks/index.ts @@ -0,0 +1,335 @@ +const SpecCapability = { + podCount: 'urn:alm:descriptor:com.tectonic.ui:podCount', + endpointList: 'urn:alm:descriptor:com.tectonic.ui:endpointList', + label: 'urn:alm:descriptor:com.tectonic.ui:label', + resourceRequirements: 'urn:alm:descriptor:com.tectonic.ui:resourceRequirements', + selector: 'urn:alm:descriptor:com.tectonic.ui:selector:', + namespaceSelector: 'urn:alm:descriptor:com.tectonic.ui:namespaceSelector', + k8sResourcePrefix: 'urn:alm:descriptor:io.kubernetes:', + booleanSwitch: 'urn:alm:descriptor:com.tectonic.ui:booleanSwitch', + password: 'urn:alm:descriptor:com.tectonic.ui:password', + checkbox: 'urn:alm:descriptor:com.tectonic.ui:checkbox', + imagePullPolicy: 'urn:alm:descriptor:com.tectonic.ui:imagePullPolicy', + updateStrategy: 'urn:alm:descriptor:com.tectonic.ui:updateStrategy', + text: 'urn:alm:descriptor:com.tectonic.ui:text', + number: 'urn:alm:descriptor:com.tectonic.ui:number', + nodeAffinity: 'urn:alm:descriptor:com.tectonic.ui:nodeAffinity', + podAffinity: 'urn:alm:descriptor:com.tectonic.ui:podAffinity', + podAntiAffinity: 'urn:alm:descriptor:com.tectonic.ui:podAntiAffinity', + fieldGroup: 'urn:alm:descriptor:com.tectonic.ui:fieldGroup:', + arrayFieldGroup: 'urn:alm:descriptor:com.tectonic.ui:arrayFieldGroup:', + select: 'urn:alm:descriptor:com.tectonic.ui:select:', + advanced: 'urn:alm:descriptor:com.tectonic.ui:advanced', + fieldDependency: 'urn:alm:descriptor:com.tectonic.ui:fieldDependency:', + hidden: 'urn:alm:descriptor:com.tectonic.ui:hidden', +} as const; + +const StatusCapability = { + 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', + k8sResourcePrefix: 'urn:alm:descriptor:io.kubernetes:', + hidden: 'urn:alm:descriptor:com.tectonic.ui:hidden', +} as const; + +const prefixedCapabilities = new Set([ + SpecCapability.selector, + SpecCapability.k8sResourcePrefix, + SpecCapability.fieldGroup, + SpecCapability.arrayFieldGroup, + SpecCapability.select, + StatusCapability.k8sResourcePrefix, +]); + +function defaultValueFor(capability: string): unknown { + switch (capability) { + case SpecCapability.podCount: + return 3; + case SpecCapability.endpointList: + return [{ port: 8080, scheme: 'TCP' }]; + case SpecCapability.label: + return 'app=openshift'; + case SpecCapability.resourceRequirements: + return { + limits: { cpu: '500m', memory: '50Mi', 'ephemeral-storage': '500Gi' }, + requests: { cpu: '500m', memory: '50Mi', 'ephemeral-storage': '500Gi' }, + }; + case SpecCapability.namespaceSelector: + return { matchNames: ['default'] }; + case SpecCapability.booleanSwitch: + return true; + case SpecCapability.password: + return 'password123'; + case SpecCapability.checkbox: + return true; + case SpecCapability.imagePullPolicy: + return 'Never'; + case SpecCapability.updateStrategy: + return { type: 'Recreate' }; + case SpecCapability.text: + return 'Some text'; + case SpecCapability.number: + return 2; + case SpecCapability.select: + return ''; + case StatusCapability.podStatuses: + return { ready: ['pod-0', 'pod-1'], unhealthy: ['pod-2'], stopped: ['pod-3'] }; + case StatusCapability.podCount: + return 3; + case StatusCapability.w3Link: + return 'https://google.com'; + case StatusCapability.conditions: + return [ + { + type: 'Available', + status: 'True', + lastUpdateTime: '2018-08-22T23:27:55Z', + lastTransitionTime: '2018-08-22T23:27:55Z', + reason: 'AppReady', + message: 'App is ready.', + }, + ]; + case StatusCapability.text: + return 'Some text'; + case StatusCapability.prometheusEndpoint: + return 'my-svc.my-namespace.svc.cluster.local'; + case StatusCapability.k8sPhase: + return 'Available'; + case StatusCapability.k8sPhaseReason: + return 'AppReady'; + default: + return null; + } +} + +const testLabel = 'automatedTestName'; + +function buildSpecEntries(capabilityMap: Record) { + return Object.keys(capabilityMap) + .filter((c) => !prefixedCapabilities.has(capabilityMap[c])) + .reduce( + (acc, cur) => ({ ...acc, [cur]: defaultValueFor(capabilityMap[cur]) }), + {} as Record, + ); +} + +function buildDescriptors(capabilityMap: Record) { + return Object.keys(capabilityMap) + .filter((c) => !prefixedCapabilities.has(capabilityMap[c])) + .map((capability) => ({ + description: `Spec descriptor for ${capability}`, + displayName: capability.replace(/([A-Z])/g, ' $1').replace(/^./, (str) => str.toUpperCase()), + path: capability, + 'x-descriptors': [capabilityMap[capability]], + })); +} + +export function createTestCRD(testName: string) { + return { + apiVersion: 'apiextensions.k8s.io/v1', + kind: 'CustomResourceDefinition', + metadata: { + name: 'apps.test.tectonic.com', + labels: { [testLabel]: testName }, + }, + spec: { + group: 'test.tectonic.com', + scope: 'Namespaced', + names: { plural: 'apps', singular: 'app', kind: 'App', listKind: 'Apps' }, + versions: [ + { + name: 'v1', + 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' } }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + }; +} + +export function createTestCR(testName: string) { + const crd = createTestCRD(testName); + return { + apiVersion: `${crd.spec.group}/${crd.spec.versions[0].name}`, + kind: crd.spec.names.kind, + metadata: { + name: 'olm-descriptors-test', + namespace: testName, + labels: { [testLabel]: testName }, + }, + spec: { + fieldGroup: { itemOne: 'Field group item 1', itemTwo: 2 }, + arrayFieldGroup: [{ itemOne: 'Array field group item 1', itemTwo: 2 }], + select: 'WARN', + ...buildSpecEntries((SpecCapability as unknown) as Record), + }, + status: { + ...buildSpecEntries((StatusCapability as unknown) as Record), + }, + }; +} + +export function createTestCSV(testName: string) { + const crd = createTestCRD(testName); + const cr = createTestCR(testName); + return { + apiVersion: 'operators.coreos.com/v1alpha1', + kind: 'ClusterServiceVersion', + metadata: { + name: 'olm-descriptors-test', + namespace: testName, + labels: { [testLabel]: testName }, + 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.metadata.name, + version: crd.spec.versions[0].name, + kind: crd.spec.names.kind, + displayName: crd.spec.names.kind, + description: 'Application instance for testing descriptors', + resources: [], + specDescriptors: buildDescriptors( + (SpecCapability as unknown) as Record, + ), + statusDescriptors: buildDescriptors( + (StatusCapability as unknown) as Record, + ), + }, + ], + }, + }, + }; +} + +export function createTestCatalogSource(testName: string) { + return { + apiVersion: 'operators.coreos.com/v1alpha1', + kind: 'CatalogSource', + metadata: { + name: testName, + namespace: testName, + labels: { [testLabel]: testName }, + }, + spec: { + displayName: 'Test catalog', + image: '', + sourceType: 'grpc', + updateStrategy: { registryPoll: { interval: '10m' } }, + }, + }; +} + +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/e2e/tests/olm/packageserver-tabs.spec.ts b/frontend/e2e/tests/olm/packageserver-tabs.spec.ts new file mode 100644 index 00000000000..55fde073403 --- /dev/null +++ b/frontend/e2e/tests/olm/packageserver-tabs.spec.ts @@ -0,0 +1,86 @@ +import { test, expect } from '../../fixtures'; +import { DetailsPage } from '../../pages/details-page'; +import { YamlEditorPage } from '../../pages/yaml-editor-page'; + +test.describe('PackageManifest tabs rendering', { tag: ['@admin'] }, () => { + const csvNamespace = 'openshift-operator-lifecycle-manager'; + const csvName = 'packageserver'; + const packageManifestName = '3scale-operator'; + const baseUrl = `/k8s/ns/${csvNamespace}/operators.coreos.com~v1alpha1~ClusterServiceVersion/${csvName}/packages.operators.coreos.com~v1~PackageManifest/${packageManifestName}`; + const sectionHeader = 'PackageManifest overview'; + + test('renders Details tab correctly', async ({ page }) => { + const detailsPage = new DetailsPage(page); + + await page.goto(baseUrl); + await detailsPage.isLoaded(); + await detailsPage.titleShouldContain(packageManifestName); + await detailsPage.sectionHeaderShouldExist(sectionHeader); + }); + + test('renders YAML tab correctly', async ({ page }) => { + const yamlEditorPage = new YamlEditorPage(page); + + await page.goto(`${baseUrl}/yaml`); + await yamlEditorPage.isLoaded(); + + const content = await yamlEditorPage.getEditorContent(); + expect(content).toContain(packageManifestName); + expect(content).toContain('PackageManifest'); + }); + + test('renders Resources tab correctly', async ({ page }) => { + const detailsPage = new DetailsPage(page); + + await page.goto(`${baseUrl}/resources`); + await detailsPage.isLoaded(); + await expect(page.getByTestId('console-empty-state')).toBeAttached(); + }); + + test('renders Events tab correctly', async ({ page }) => { + const detailsPage = new DetailsPage(page); + + await page.goto(`${baseUrl}/events`); + await detailsPage.isLoaded(); + await expect(page.getByTestId('console-empty-state')).toBeAttached(); + }); + + test('allows navigation between tabs', async ({ page }) => { + const detailsPage = new DetailsPage(page); + const yamlEditorPage = new YamlEditorPage(page); + + await test.step('Start at Details tab', async () => { + await page.goto(baseUrl); + await detailsPage.isLoaded(); + }); + + await test.step('Navigate to YAML tab', async () => { + await detailsPage.selectTab('YAML'); + await yamlEditorPage.isLoaded(); + await expect(page).toHaveURL(/\/yaml/); + }); + + await test.step('Navigate to Resources tab', async () => { + await detailsPage.selectTab('Resources'); + await detailsPage.isLoaded(); + await expect(page).toHaveURL(/\/resources/); + await expect(page.getByTestId('console-empty-state')).toBeAttached(); + }); + + await test.step('Navigate to Events tab', async () => { + await detailsPage.selectTab('Events'); + await detailsPage.isLoaded(); + await expect(page).toHaveURL(/\/events/); + await expect(page.getByTestId('console-empty-state')).toBeAttached(); + }); + + await test.step('Navigate back to Details tab', async () => { + await detailsPage.selectTab('Details'); + await detailsPage.isLoaded(); + await expect(page).not.toHaveURL(/\/yaml/); + await expect(page).not.toHaveURL(/\/resources/); + await expect(page).not.toHaveURL(/\/events/); + await detailsPage.sectionHeaderShouldExist(sectionHeader); + }); + }); +}); From 0cb5c75af500e93dc09d12733ca9463138565a25 Mon Sep 17 00:00:00 2001 From: Robert Luby Date: Fri, 15 May 2026 14:09:27 +0200 Subject: [PATCH 2/2] CONSOLE-5292: remove OLM cypress tests --- .gitignore | 1 + frontend/e2e/clients/kubernetes-client.ts | 157 ++++++++ frontend/e2e/fixtures/cleanup-fixture.ts | 30 ++ .../lib/config/testing-library-tests.js | 1 + .../integration-tests/mocks/index.tsx | 350 ------------------ .../tests/catalog-source-details.cy.ts | 107 ------ .../tests/create-namespace.cy.ts | 66 ---- .../tests/deprecated-operator-warnings.cy.ts | 277 -------------- .../integration-tests/tests/descriptors.cy.ts | 136 ------- .../tests/edit-default-sources.cy.ts | 46 --- .../tests/packageserver-tabs.cy.ts | 101 ----- 11 files changed, 189 insertions(+), 1083 deletions(-) delete mode 100644 frontend/packages/operator-lifecycle-manager/integration-tests/mocks/index.tsx delete mode 100644 frontend/packages/operator-lifecycle-manager/integration-tests/tests/catalog-source-details.cy.ts delete mode 100644 frontend/packages/operator-lifecycle-manager/integration-tests/tests/create-namespace.cy.ts delete mode 100644 frontend/packages/operator-lifecycle-manager/integration-tests/tests/deprecated-operator-warnings.cy.ts delete mode 100644 frontend/packages/operator-lifecycle-manager/integration-tests/tests/descriptors.cy.ts delete mode 100644 frontend/packages/operator-lifecycle-manager/integration-tests/tests/edit-default-sources.cy.ts delete mode 100644 frontend/packages/operator-lifecycle-manager/integration-tests/tests/packageserver-tabs.cy.ts diff --git a/.gitignore b/.gitignore index 7b9a9530880..641be0caf4f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ cypress-a11y-report.json /dynamic-demo-plugin/**/dist **/.claude/settings.local.json **/chartstore-*/ +.playwright-mcp/** diff --git a/frontend/e2e/clients/kubernetes-client.ts b/frontend/e2e/clients/kubernetes-client.ts index a9255d43cf4..97da279e0ce 100644 --- a/frontend/e2e/clients/kubernetes-client.ts +++ b/frontend/e2e/clients/kubernetes-client.ts @@ -467,6 +467,163 @@ export default class KubernetesClient { } } + async createClusterCustomResource( + group: string, + version: string, + plural: string, + body: Record, + ): Promise { + const response = await this.coApi.createClusterCustomObject({ + body, + group, + plural, + version, + }); + return response; + } + + async deleteClusterCustomResource( + group: string, + version: string, + plural: string, + name: string, + ): Promise { + try { + await this.coApi.deleteClusterCustomObject({ group, name, plural, version }); + } catch (err) { + if (!isNotFound(err)) { + throw err; + } + } + } + + async getClusterCustomResource( + group: string, + version: string, + plural: string, + name: string, + ): Promise { + const response = await this.coApi.getClusterCustomObject({ group, name, plural, version }); + return response; + } + + 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 []; + } + } + + private async mergePatch(apiPath: string, patch: Record): Promise { + const cluster = this.kubeConfig.getCurrentCluster(); + if (!cluster?.server) { + throw new Error('No cluster configured in kubeconfig'); + } + const url = new URL(apiPath, cluster.server); + const opts: https.RequestOptions = { + hostname: url.hostname, + port: url.port || 443, + path: url.pathname, + method: 'PATCH', + headers: { 'Content-Type': 'application/merge-patch+json', Accept: 'application/json' }, + rejectUnauthorized: false, + }; + await this.kubeConfig.applyToHTTPSOptions(opts); + return new Promise((resolve, reject) => { + const req = https.request(opts, (res) => { + let body = ''; + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + resolve(JSON.parse(body)); + } else { + const msg = `Merge patch failed: HTTP ${res.statusCode} ${body.substring(0, 500)}`; + reject(new Error(msg)); + } + }); + }); + req.on('error', reject); + req.write(JSON.stringify(patch)); + req.end(); + }); + } + + async patchCustomResource( + group: string, + version: string, + namespace: string, + plural: string, + name: string, + patch: Record, + ): Promise { + const apiPath = `/apis/${group}/${version}/namespaces/${namespace}/${plural}/${name}`; + return this.mergePatch(apiPath, patch); + } + + async patchClusterCustomResource( + group: string, + version: string, + plural: string, + name: string, + patch: Record, + ): Promise { + const apiPath = `/apis/${group}/${version}/${plural}/${name}`; + return this.mergePatch(apiPath, patch); + } + + async waitForCustomResourceCondition( + group: string, + version: string, + namespace: string, + plural: string, + name: string, + conditionFn: (resource: any) => boolean, + timeoutMs: number, + ): Promise { + return pollUntil( + async () => { + try { + const resource = await this.getCustomResource(group, version, namespace, plural, name); + return conditionFn(resource); + } catch { + return false; + } + }, + timeoutMs, + 2_000, + ); + } + + async waitForClusterCustomResourceCondition( + group: string, + version: string, + plural: string, + name: string, + conditionFn: (resource: any) => boolean, + timeoutMs: number, + ): Promise { + return pollUntil( + async () => { + try { + const resource = await this.getClusterCustomResource(group, version, plural, name); + return conditionFn(resource); + } catch { + return false; + } + }, + timeoutMs, + 2_000, + ); + } + async getPods(namespace: string): Promise { const response = await this.k8sApi.listNamespacedPod({ namespace }); return response.items || []; diff --git a/frontend/e2e/fixtures/cleanup-fixture.ts b/frontend/e2e/fixtures/cleanup-fixture.ts index 4cfb3aa9a1d..b7868982099 100644 --- a/frontend/e2e/fixtures/cleanup-fixture.ts +++ b/frontend/e2e/fixtures/cleanup-fixture.ts @@ -23,6 +23,13 @@ export interface CleanupFixture { plural: string, type?: string, ): void; + trackClusterCustomResource( + name: string, + apiGroup: string, + apiVersion: string, + plural: string, + type?: string, + ): void; readonly count: number; executeCleanup(): Promise; shouldSkipCleanup(): boolean; @@ -95,6 +102,22 @@ export function createCleanupFixture(testName: string): CleanupFixture { }); }, + trackClusterCustomResource( + name: string, + apiGroup: string, + apiVersion: string, + plural: string, + type?: string, + ) { + resources.push({ + name, + apiGroup, + apiVersion, + plural, + type: type || plural, + }); + }, + get count() { return resources.length; }, @@ -142,6 +165,13 @@ export function createCleanupFixture(testName: string): CleanupFixture { resource.plural, resource.name, ); + } else if (resource.apiGroup) { + await client.deleteClusterCustomResource( + resource.apiGroup, + resource.apiVersion, + resource.plural, + resource.name, + ); } } catch (error) { const msg = error instanceof Error ? error.message : String(error); diff --git a/frontend/packages/eslint-plugin-console/lib/config/testing-library-tests.js b/frontend/packages/eslint-plugin-console/lib/config/testing-library-tests.js index fcef4fe1301..107e7606b12 100644 --- a/frontend/packages/eslint-plugin-console/lib/config/testing-library-tests.js +++ b/frontend/packages/eslint-plugin-console/lib/config/testing-library-tests.js @@ -18,6 +18,7 @@ module.exports = { files: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], plugins: ['testing-library'], extends: ['plugin:testing-library/react'], + excludedFiles: ['e2e/**'], rules: { 'testing-library/prefer-explicit-assert': 'error', 'testing-library/prefer-user-event': 'error', diff --git a/frontend/packages/operator-lifecycle-manager/integration-tests/mocks/index.tsx b/frontend/packages/operator-lifecycle-manager/integration-tests/mocks/index.tsx deleted file mode 100644 index ea75f1906d7..00000000000 --- a/frontend/packages/operator-lifecycle-manager/integration-tests/mocks/index.tsx +++ /dev/null @@ -1,350 +0,0 @@ -import { testName } from '../../../integration-tests/support'; -import { SpecCapability, StatusCapability } from '../../src/components/descriptors/types'; - -const defaultValueFor = (capability: C) => { - switch (capability) { - case SpecCapability.podCount: - return 3; - case SpecCapability.endpointList: - return [{ port: 8080, scheme: 'TCP' }]; - case SpecCapability.label: - return 'app=openshift'; - case SpecCapability.resourceRequirements: - return { - limits: { cpu: '500m', memory: '50Mi', 'ephemeral-storage': '500Gi' }, - requests: { cpu: '500m', memory: '50Mi', 'ephemeral-storage': '500Gi' }, - }; - case SpecCapability.namespaceSelector: - return { matchNames: ['default'] }; - case SpecCapability.booleanSwitch: - return true; - case SpecCapability.password: - return 'password123'; - case SpecCapability.checkbox: - return true; - case SpecCapability.imagePullPolicy: - return 'Never'; - case SpecCapability.updateStrategy: - return { type: 'Recreate' }; - case SpecCapability.text: - return 'Some text'; - case SpecCapability.number: - return 2; - case SpecCapability.select: - return ''; - - case StatusCapability.podStatuses: - return { ready: ['pod-0', 'pod-1'], unhealthy: ['pod-2'], stopped: ['pod-3'] }; - case StatusCapability.podCount: - return 3; - case StatusCapability.w3Link: - return 'https://google.com'; - case StatusCapability.conditions: - return [ - { - type: 'Available', - status: 'True', - lastUpdateTime: '2018-08-22T23:27:55Z', - lastTransitionTime: '2018-08-22T23:27:55Z', - reason: 'AppReady', - message: 'App is ready.', - }, - ]; - case StatusCapability.text: - return 'Some text'; - case StatusCapability.prometheusEndpoint: - return 'my-svc.my-namespace.svc.cluster.local'; - case StatusCapability.k8sPhase: - return 'Available'; - case StatusCapability.k8sPhaseReason: - return 'AppReady'; - - default: - return null; - } -}; - -const testLabel = 'automatedTestName'; -const prefixedCapabilities = new Set([ - SpecCapability.selector, - SpecCapability.k8sResourcePrefix, - SpecCapability.fieldGroup, - SpecCapability.arrayFieldGroup, - SpecCapability.select, - StatusCapability.k8sResourcePrefix, -]); - -export const testCRD = { - apiVersion: 'apiextensions.k8s.io/v1', - kind: 'CustomResourceDefinition', - metadata: { - name: 'apps.test.tectonic.com', - labels: { [testLabel]: testName }, - }, - spec: { - group: 'test.tectonic.com', - scope: 'Namespaced', - names: { - plural: 'apps', - singular: 'app', - kind: 'App', - listKind: 'Apps', - }, - versions: [ - { - name: 'v1', - 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', - }, - }, - }, - }, - }, - }, - }, - }, - }, - ], - }, -}; - -export const testCR = { - apiVersion: `${testCRD.spec.group}/${testCRD.spec.versions[0].name}`, - kind: testCRD.spec.names.kind, - metadata: { - name: 'olm-descriptors-test', - namespace: testName, - labels: { [testLabel]: testName }, - }, - spec: { - fieldGroup: { - itemOne: 'Field group item 1', - itemTwo: 2, - }, - arrayFieldGroup: [ - { - itemOne: 'Array field group item 1', - itemTwo: 2, - }, - ], - select: 'WARN', - ...Object.keys(SpecCapability) - .filter((c) => !prefixedCapabilities.has(SpecCapability[c])) - .reduce( - (acc, cur) => ({ - ...acc, - [cur]: defaultValueFor(SpecCapability[cur]), - }), - {}, - ), - }, - status: { - ...Object.keys(StatusCapability) - .filter((c) => !prefixedCapabilities.has(StatusCapability[c])) - .reduce( - (acc, cur) => ({ - ...acc, - [cur]: defaultValueFor(StatusCapability[cur]), - }), - {}, - ), - }, -}; - -export const testCSV = { - apiVersion: 'operators.coreos.com/v1alpha1', - kind: 'ClusterServiceVersion', - metadata: { - name: 'olm-descriptors-test', - namespace: testName, - labels: { [testLabel]: testName }, - annotations: { 'alm-examples': JSON.stringify([testCR]) }, - }, - 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: testCRD.metadata.name, - version: testCRD.spec.versions[0].name, - kind: testCRD.spec.names.kind, - displayName: testCRD.spec.names.kind, - description: 'Application instance for testing descriptors', - resources: [], - specDescriptors: Object.keys(SpecCapability) - .filter((c) => !prefixedCapabilities.has(SpecCapability[c])) - .map((capability) => ({ - description: `Spec descriptor for ${capability}`, - displayName: capability - .replace(/([A-Z])/g, ' $1') - .replace(/^./, (str) => str.toUpperCase()), - path: capability, - 'x-descriptors': [SpecCapability[capability]], - })), - statusDescriptors: Object.keys(StatusCapability) - .filter((c) => !prefixedCapabilities.has(StatusCapability[c])) - .map((capability) => ({ - description: `Status descriptor for ${capability}`, - displayName: capability - .replace(/([A-Z])/g, ' $1') - .replace(/^./, (str) => str.toUpperCase()), - path: capability, - 'x-descriptors': [StatusCapability[capability]], - })), - }, - ], - }, - }, -}; - -export const testCatalogSource = { - apiVersion: 'operators.coreos.com/v1alpha1', - kind: 'CatalogSource', - metadata: { - name: testName, - namespace: testName, - labels: { [testLabel]: testName }, - }, - spec: { - displayName: 'Test catalog', - image: '', - sourceType: 'grpc', - updateStrategy: { - registryPoll: { - interval: '10m', - }, - }, - }, -}; - -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/create-namespace.cy.ts b/frontend/packages/operator-lifecycle-manager/integration-tests/tests/create-namespace.cy.ts deleted file mode 100644 index 45aa28f0f2b..00000000000 --- a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/create-namespace.cy.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { checkErrors, testName } from '@console/cypress-integration-tests/support'; -import { modal } from '@console/cypress-integration-tests/views/modal'; - -describe('Create namespace from install operators', () => { - before(() => { - cy.login(); - cy.createProjectWithCLI(testName); - }); - - afterEach(() => { - checkErrors(); - }); - - after(() => { - cy.deleteProjectWithCLI(testName); - }); - - const nsName = `${testName}-ns`; - - it('creates namespace from operator install page', () => { - const operatorSelector = 'operator-Red Hat Integration - 3scale'; - const operatorName = 'Red Hat Integration - 3scale'; - cy.log('test namespace creation from dropdown'); - cy.visit(`/catalog/ns/${testName}`); - cy.byTestID('tab operator').click(); - cy.byTestID('search-catalog').type(operatorName); - cy.url().should('include', 'keyword'); - cy.byTestID(operatorSelector).click(); - // Wait for the Install button to be visible and have a valid href before clicking. - // The button is conditionally rendered based on useCtaLink hook, which processes - // the CTA href asynchronously. Clicking before href is set causes navigation to fail. - cy.byTestID('catalog-details-modal-cta').should('be.visible').and('have.attr', 'href'); - cy.byTestID('catalog-details-modal-cta').click(); - - // 3scale 2.11 supports only installation mode 'A specific namespace', - // so it was automatically selected. - // But starting with 2.12 it also supports 'All namespaces'. - // So it is required to select this radio option to specify the namespace. - // Regression test: Wait for radio button to be visible before clicking to avoid race conditions - // where the form re-renders asynchronously after the channel/version selectors load. - cy.byTestID('A specific namespace on the cluster-radio-input').should('be.visible').click(); - - // configure operator install ("^=Create_"" will match "Create_Namespace" and "Create_Project") - cy.byTestID('dropdown-selectbox').click().get('[data-test-dropdown-menu^="Create_"]').click(); - - // verify namespace modal is opened - modal.shouldBeOpened(); - cy.byTestID('input-name').type(nsName); - modal.submit(); - modal.shouldBeClosed(); - - // verify the dropdown selection shows the newly created namespace - cy.byTestID('dropdown-selectbox').should('contain', `${nsName}`); - - cy.get('button').contains('Install').click(); - - // verify operator began installation - cy.byTestID('view-installed-operators-btn').should( - 'contain', - `View installed Operators in Namespace ${nsName}`, - ); - - // Verify namespace was created successfully - cy.deleteProject(nsName); - }); -}); diff --git a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/deprecated-operator-warnings.cy.ts b/frontend/packages/operator-lifecycle-manager/integration-tests/tests/deprecated-operator-warnings.cy.ts deleted file mode 100644 index 752d4a8ec76..00000000000 --- a/frontend/packages/operator-lifecycle-manager/integration-tests/tests/deprecated-operator-warnings.cy.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { checkErrors, create, testName } from '@console/cypress-integration-tests/support'; -import { testDeprecatedCatalogSource, testDeprecatedSubscription } from '../mocks'; -import { operator } from '../views/operator.view'; - -const TIMEOUT = { timeout: 300000 }; -const testOperatorName = 'Kiali Community Operator'; -const testOperator = { - name: 'Kiali Operator', -}; -const deprecatedBadge = 'Deprecated'; -const deprecatedPackageMessage = 'package kiali is end of life'; -const deprecatedChannelMessage = 'channel alpha is no longer supported'; -const deprecatedVersionMessage = 'kiali-operator.v1.68.0 is deprecated'; -const DEPRECATED_OPERATOR_WARNING_BADGE_ID = 'deprecated-operator-warning-badge'; -const DEPRECATED_OPERATOR_WARNING_PACKAGE_ID = 'deprecated-operator-warning-package'; -const DEPRECATED_OPERATOR_WARNING_CHANNEL_ID = 'deprecated-operator-warning-channel'; -const DEPRECATED_OPERATOR_WARNING_VERSION_ID = 'deprecated-operator-warning-version'; - -describe('Deprecated operator warnings', () => { - const subscriptionName = testDeprecatedSubscription.metadata.name; - const subscriptionNamespace = testDeprecatedSubscription.metadata.namespace; - const csvName = testDeprecatedSubscription.spec.startingCSV; - const catalogSourceName = testDeprecatedCatalogSource.metadata.name; - const catalogSourceNamespace = testDeprecatedCatalogSource.metadata.namespace; - - const cleanupOperatorResources = () => { - // Delete subscription first to stop operator reconciliation - cy.exec( - `oc delete subscription ${subscriptionName} -n ${subscriptionNamespace} --ignore-not-found --wait=false`, - { failOnNonZeroExit: false, timeout: 60000 }, - ); - // Delete CSV to remove the operator - cy.exec( - `oc delete clusterserviceversion ${csvName} -n ${subscriptionNamespace} --ignore-not-found --wait=false`, - { failOnNonZeroExit: false, timeout: 60000 }, - ); - // Delete any InstallPlans related to the operator - cy.exec( - `oc delete installplan -n ${subscriptionNamespace} -l operators.coreos.com/${subscriptionName}.${subscriptionNamespace}= --ignore-not-found --wait=false`, - { failOnNonZeroExit: false, timeout: 60000 }, - ); - }; - - before(() => { - cy.login(); - // Clean up any existing resources from previous failed runs - cleanupOperatorResources(); - cy.exec( - `oc delete catalogsource ${catalogSourceName} -n ${catalogSourceNamespace} --ignore-not-found --wait=false`, - { failOnNonZeroExit: false, timeout: 60000 }, - ); - create(testDeprecatedCatalogSource); - }); - - after(() => { - cy.visit('/'); - // Clean up operator resources - cleanupOperatorResources(); - // Clean up catalog source - cy.exec( - `oc delete catalogsource ${catalogSourceName} -n ${catalogSourceNamespace} --ignore-not-found --wait=false`, - { failOnNonZeroExit: false, timeout: 60000 }, - ); - checkErrors(); - }); - - it('verify deprecated Operator warning badge on the Operator tile', () => { - cy.visit( - `/k8s/ns/${testDeprecatedCatalogSource.metadata.namespace}/operators.coreos.com~v1alpha1~CatalogSource/test-community-operator-deprecation`, - ); - cy.log('verify the test-community-operator-deprecation CatalogSource is in "READY" status'); - cy.byTestSelector('details-item-value__Status', TIMEOUT).should('have.text', 'READY'); - - cy.log('visit Software Catalog'); - cy.visit(`/catalog/ns/${testName}`); - cy.byTestID('tab operator').click(); - - cy.log('filter by the group name'); - cy.byTestID('source-community-operators-for-testing-deprecation').click(); - - cy.log('filter by the operator name'); - cy.byTestID('search-catalog').type(testOperatorName); - cy.get('.co-catalog-tile', TIMEOUT).its('length').should('eq', 1); - - cy.log('verify the Deprecated badge on Kiali Community Operator tile'); - cy.byTestID('Deprecated-badge').contains(deprecatedBadge).should('exist'); - }); - - it('verify deprecated Operator warnings in the Operator details panel', () => { - cy.visit( - `/catalog/ns/${testName}?catalogType=operator&keyword=kia&selectedId=kiali-test-community-operator-deprecation-openshift-marketplace&channel=stable&version=1.83.0`, - ); - cy.log('verify the deprecated operator badge exists'); - cy.byTestID('Deprecated-badge').contains(deprecatedBadge).should('exist'); - - cy.log('verify the package deprecation warning exists when viewing a deprecated operator'); - cy.byTestID('deprecated-operator-warning-package') - .contains(deprecatedPackageMessage) - .should('exist'); - }); - - it('verify deprecated channel warnings in the Operator details panel', () => { - cy.visit( - `/catalog/ns/${testName}?catalogType=operator&keyword=kia&selectedId=kiali-test-community-operator-deprecation-openshift-marketplace&channel=stable&version=1.83.0`, - ); - - cy.log('verify the channel deprecation warnings do not exist yet'); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_PACKAGE_ID) - .contains(deprecatedChannelMessage) - .should('not.exist'); - cy.byTestID('deprecated-operator-warning-channel-icon').should('not.exist'); - cy.log('verify the channel deprecation warning icon exists in the channel select menu'); - // force click because parent PF modal component causes button not to be "visible" - cy.byTestID('operator-channel-select-toggle').should('exist').click({ - force: true, - }); - cy.byTestID('deprecated-operator-warning-channel-icon').should('exist'); - // force click because parent PF modal component causes button not to be "visible" - cy.get('[data-test="channel-option-alpha"] > button').click({ force: true }); - - cy.log('verify the channel deprecation alert exists after selecting a deprecated channel'); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_CHANNEL_ID) - .contains(deprecatedChannelMessage) - .should('exist'); - }); - - it('verify deprecated version warnings in the Operator details panel', () => { - cy.visit( - `/catalog/ns/${testName}?catalogType=operator&keyword=kia&selectedId=kiali-test-community-operator-deprecation-openshift-marketplace&channel=stable&version=1.83.0`, - ); - - cy.log('verify the version deprecation warnings do not exist yet'); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_VERSION_ID) - .contains(deprecatedVersionMessage) - .should('not.exist'); - cy.byTestID('deprecated-operator-warning-version-icon').should('not.exist'); - cy.log('verify the version deprecation warning icon exists in the version select menu'); - // force click because parent PF modal component causes button not to be "visible" - cy.byTestID('operator-version-select-toggle').click({ - force: true, - }); - cy.byTestID('deprecated-operator-warning-version-icon').should('exist'); - // force click because parent PF modal component causes button not to be "visible" - cy.get('[data-test="version-option-kiali-operator.v1.68.0"] > button').click({ force: true }); - cy.log( - 'verify the version deprecation warning alert exists after selecting a deprecated version', - ); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_VERSION_ID) - .contains(deprecatedVersionMessage) - .should('exist'); - }); - - it('verify deprecated Operator warnings on Install Operator details page', () => { - cy.log('visit the Install Operator details page'); - cy.visit( - '/operatorhub/subscribe?pkg=kiali&catalog=test-community-operator-deprecation&catalogNamespace=openshift-marketplace&targetNamespace=undefined&channel=alpha&version=1.68.0', - ); - - cy.log('verify the Deprecated badge on Kiali Community Operator logo'); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_BADGE_ID).contains(deprecatedBadge).should('exist'); - - cy.log('verify the deprecation warning messages exists'); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_PACKAGE_ID) - .contains(deprecatedPackageMessage) - .should('exist'); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_CHANNEL_ID) - .contains(deprecatedChannelMessage) - .should('exist'); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_VERSION_ID) - .contains(deprecatedVersionMessage) - .should('exist'); - }); - - // Tests for deprecation warnings on INSTALLED operators - describe('Installed Operator deprecation warnings', () => { - before(() => { - const subscriptionYaml = JSON.stringify(testDeprecatedSubscription); - - cy.log('Install operator via CLI'); - cy.exec(`echo '${subscriptionYaml}' | oc apply -f -`, { timeout: 60000 }); - - cy.log('Wait for InstallPlan to be created'); - cy.exec( - `oc wait subscription/${subscriptionName} -n ${subscriptionNamespace} ` + - `--for=jsonpath='{.status.installPlanRef.name}' --timeout=120s`, - { timeout: 150000 }, - ); - - cy.log('Approve InstallPlan via CLI'); - // eslint-disable-next-line promise/catch-or-return - cy.exec( - `oc get installplan -n ${subscriptionNamespace} -o jsonpath=` + - `'{.items[?(@.spec.clusterServiceVersionNames[*]=="${csvName}")].metadata.name}'`, - { timeout: 60000 }, - ).then((result) => { - const installPlanName = result.stdout.trim(); - if (installPlanName) { - return cy.exec( - `oc patch installplan ${installPlanName} -n ${subscriptionNamespace} ` + - `--type merge -p '{"spec":{"approved":true}}'`, - { timeout: 60000 }, - ); - } - return cy.wrap(null); - }); - - cy.log('Wait for CSV success and deprecation conditions'); - cy.exec( - `oc wait csv/${csvName} -n ${subscriptionNamespace} ` + - `--for=jsonpath='{.status.phase}'=Succeeded --timeout=300s && ` + - `oc wait subscription/${subscriptionName} -n ${subscriptionNamespace} ` + - `--for=condition=PackageDeprecated --timeout=180s`, - { failOnNonZeroExit: false, timeout: 500000 }, - ); - }); - - it('displays deprecated badge on Installed Operators list page', () => { - cy.visit( - `/k8s/ns/${subscriptionNamespace}/operators.coreos.com~v1alpha1~ClusterServiceVersion`, - ); - operator.filterByName(testOperator.name); - cy.byTestOperatorRow(testOperator.name).should('exist'); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_BADGE_ID, TIMEOUT) - .should('exist') - .and('contain.text', deprecatedBadge); - }); - - it('displays deprecation warnings on CSV details page', () => { - cy.visit( - `/k8s/ns/${subscriptionNamespace}/operators.coreos.com~v1alpha1~ClusterServiceVersion/${csvName}`, - ); - cy.byLegacyTestID('horizontal-link-Details', { timeout: 60000 }).should('exist'); - - cy.byTestID(DEPRECATED_OPERATOR_WARNING_BADGE_ID, TIMEOUT).should( - 'contain.text', - deprecatedBadge, - ); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_PACKAGE_ID, TIMEOUT).should( - 'contain.text', - deprecatedPackageMessage, - ); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_CHANNEL_ID, TIMEOUT).should( - 'contain.text', - deprecatedChannelMessage, - ); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_VERSION_ID, TIMEOUT).should( - 'contain.text', - deprecatedVersionMessage, - ); - }); - - it('displays deprecation warnings on CSV subscription tab', () => { - cy.visit( - `/k8s/ns/${subscriptionNamespace}/operators.coreos.com~v1alpha1~ClusterServiceVersion/${csvName}/subscription`, - ); - cy.byLegacyTestID('horizontal-link-Subscription', { timeout: 60000 }).should('exist'); - - cy.byTestID(DEPRECATED_OPERATOR_WARNING_PACKAGE_ID, TIMEOUT).should( - 'contain.text', - deprecatedPackageMessage, - ); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_CHANNEL_ID, TIMEOUT).should( - 'contain.text', - deprecatedChannelMessage, - ); - cy.byTestID(DEPRECATED_OPERATOR_WARNING_VERSION_ID, TIMEOUT).should( - 'contain.text', - deprecatedVersionMessage, - ); - cy.byTestID('deprecated-operator-warning-subscription-update-icon', TIMEOUT).should('exist'); - - cy.byTestID('subscription-channel-update-button', TIMEOUT).should('not.be.disabled').click(); - cy.get('.pf-v6-c-modal-box', { timeout: 30000 }).should('be.visible'); - cy.byTestID('kiali-operator.v1.83.0').should('exist'); - }); - }); -}); diff --git a/frontend/packages/operator-lifecycle-manager/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/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); - }); -});