diff --git a/frontend/e2e/pages/knative/add-flow-page.ts b/frontend/e2e/pages/knative/add-flow-page.ts new file mode 100644 index 00000000000..e4c880ad79f --- /dev/null +++ b/frontend/e2e/pages/knative/add-flow-page.ts @@ -0,0 +1,133 @@ +import type { Locator } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import BasePage from '../base-page'; + +export class AddFlowPage extends BasePage { + private readonly gitUrlInput = this.page.locator('#form-input-git-url-field'); + private readonly componentNameInput = this.page.locator('#form-input-name-field'); + private readonly appNameInput = this.page.locator('#form-input-application-name-field'); + private readonly createButton = this.page.getByTestId('save-changes'); + private readonly resourcesDropdown = this.page.locator( + '#form-select-input-resources-field', + ); + private readonly knativeResourceOption = this.page.locator( + '#select-option-resources-knative', + ); + private readonly importStrategyEditButton = this.page.locator( + '.odc-import-strategy-section__edit-strategy-button', + ); + private readonly dockerfileStrategy = this.page.getByTestId('import-strategy Dockerfile'); + private readonly dockerfilePathInput = this.page.locator( + '#form-input-docker-dockerfilePath-field', + ); + private readonly externalRegistryInput = this.page.locator( + '#form-input-searchTerm-field', + ); + private readonly pageHeading = this.page.getByTestId('page-heading').locator('h1'); + + async navigateToAddPage(namespace: string): Promise { + await this.goTo(`/add/ns/${namespace}`); + await this.waitForLoadingComplete(); + } + + async clickCard(cardId: string): Promise { + const card = this.page.getByTestId(`item ${cardId}`); + await card.scrollIntoViewIfNeeded(); + await this.robustClick(card); + await this.waitForLoadingComplete(); + } + + async clickImportFromGitCard(): Promise { + await this.clickCard('import-from-git'); + } + + async clickContainerImageCard(): Promise { + await this.clickCard('deploy-image'); + } + + async enterGitUrl(url: string): Promise { + await this.gitUrlInput.clear(); + await this.gitUrlInput.fill(url); + await expect( + this.page.locator('.pf-v6-c-helper-text').filter({ hasText: /Validated|Rate limit/ }).first(), + ).toBeVisible({ timeout: 60_000 }); + + // If rate limited, auto-detection fails — manually select Builder Image strategy and Node.js + const rateLimitMsg = this.page.getByText('Rate limit exceeded'); + if ((await rateLimitMsg.count()) > 0) { + // Select "Builder Image" import strategy + const builderImageStrategy = this.page.getByTestId('import-strategy Builder Image'); + if ((await builderImageStrategy.count()) > 0) { + await this.robustClick(builderImageStrategy); + } + // Wait for builder image list to load, then select Node.js + const nodeJsImage = this.page.locator('.odc-selector-card').filter({ hasText: 'Node.js' }); + if ((await nodeJsImage.count()) > 0) { + await this.robustClick(nodeJsImage.first()); + } + } + } + + async enterComponentName(name: string): Promise { + await this.componentNameInput.scrollIntoViewIfNeeded(); + await this.componentNameInput.click(); + await this.componentNameInput.clear(); + await this.componentNameInput.fill(name); + await expect(this.componentNameInput).toHaveValue(name); + } + + async enterAppName(name: string): Promise { + await this.appNameInput.clear(); + await this.appNameInput.fill(name); + } + + async selectServerlessDeployment(): Promise { + await this.resourcesDropdown.scrollIntoViewIfNeeded(); + await this.robustClick(this.resourcesDropdown); + await this.robustClick(this.knativeResourceOption); + } + + async clickCreate(): Promise { + await this.createButton.scrollIntoViewIfNeeded(); + await expect(async () => { + await expect(this.createButton).toBeEnabled(); + }).toPass({ timeout: 90_000, intervals: [1_000, 2_000, 5_000] }); + await this.robustClick(this.createButton); + await this.waitForLoadingComplete(); + } + + async enterExternalRegistryImageName(imageName: string): Promise { + await this.externalRegistryInput.clear(); + await this.externalRegistryInput.fill(imageName); + await expect( + this.page.locator('.pf-v6-c-helper-text').filter({ hasText: /Validated|Loading/ }).first(), + ).toBeVisible({ timeout: 60_000 }); + await expect(this.componentNameInput).not.toHaveValue('', { timeout: 30_000 }); + } + + async clickEditImportStrategy(): Promise { + await this.robustClick(this.importStrategyEditButton); + } + + async selectDockerfileStrategy(): Promise { + await this.robustClick(this.dockerfileStrategy); + } + + async enterDockerfilePath(dockerfilePath: string): Promise { + await this.dockerfilePathInput.clear(); + await this.dockerfilePathInput.fill(dockerfilePath); + } + + getKnativeServiceOption(): Locator { + return this.knativeResourceOption; + } + + getResourceTypeDropdown(): Locator { + return this.resourcesDropdown; + } + + getHeading(): Locator { + return this.pageHeading; + } +} diff --git a/frontend/e2e/pages/knative/admin-eventing-page.ts b/frontend/e2e/pages/knative/admin-eventing-page.ts new file mode 100644 index 00000000000..fff58550840 --- /dev/null +++ b/frontend/e2e/pages/knative/admin-eventing-page.ts @@ -0,0 +1,40 @@ +import type { Locator } from '@playwright/test'; + +import BasePage from '../base-page'; + +export class AdminEventingPage extends BasePage { + private readonly createButton = this.page.getByTestId('tab-list-page-create'); + private readonly emptyMessage = this.page.getByTestId('empty-box-body'); + + private readonly eventSourcesTab = this.page.getByTestId('horizontal-link-Event Sources'); + private readonly brokersTab = this.page.getByTestId('horizontal-link-Brokers'); + private readonly triggersTab = this.page.getByTestId('horizontal-link-Triggers'); + private readonly channelsTab = this.page.getByTestId('horizontal-link-Channels'); + private readonly subscriptionsTab = this.page.getByTestId('horizontal-link-Subscriptions'); + + async navigateToEventing(namespace: string): Promise { + await this.goTo(`/eventing/ns/${namespace}`); + } + + getCreateButton(): Locator { + return this.createButton; + } + + getEmptyMessage(): Locator { + return this.emptyMessage; + } + + async clickTab( + tab: 'Event Sources' | 'Brokers' | 'Triggers' | 'Channels' | 'Subscriptions', + ): Promise { + const tabMap = { + 'Event Sources': this.eventSourcesTab, + Brokers: this.brokersTab, + Triggers: this.triggersTab, + Channels: this.channelsTab, + Subscriptions: this.subscriptionsTab, + }; + await this.robustClick(tabMap[tab]); + await this.waitForLoadingComplete(); + } +} diff --git a/frontend/e2e/pages/knative/topology-knative-page.ts b/frontend/e2e/pages/knative/topology-knative-page.ts new file mode 100644 index 00000000000..97a2dd3535b --- /dev/null +++ b/frontend/e2e/pages/knative/topology-knative-page.ts @@ -0,0 +1,177 @@ +import type { Locator } from '@playwright/test'; +import { expect } from '../../fixtures'; + +import BasePage from '../base-page'; + +export class TopologyKnativePage extends BasePage { + private readonly graphView = this.page.getByTestId('topology-switcher-view'); + private readonly fitToScreen = this.page.locator('#fit-to-screen'); + private readonly resetView = this.page.locator('#reset-view'); + private readonly searchInput = this.page.getByTestId('item-filter'); + private readonly highlightedNode = this.page.locator('.is-filtered').first(); + private readonly knativeServiceNode = this.page.locator('[data-type="knative-service"]'); + private readonly sidePane = this.page.getByTestId('topology-sidepane'); + private readonly sidePaneClose = this.page.getByTestId('topology-sidepane').locator('button[aria-label="Close"]'); + private readonly editAnnotationsLink = this.page.getByTestId('edit-annotations'); + private readonly modalTitle = this.page.getByTestId('modal-title'); + private readonly modalCancel = this.page.getByTestId('modal-cancel-action'); + + async navigateToTopology(namespace: string): Promise { + await this.goTo(`/topology/ns/${namespace}`); + } + + async switchToGraphView(): Promise { + const graphViewToggle = this.graphView.and(this.page.locator('[aria-label="Graph view"]')); + const switcherCount = await graphViewToggle.count(); + if (switcherCount > 0) { + await this.robustClick(graphViewToggle); + } + } + + async fitScreen(): Promise { + await this.robustClick(this.fitToScreen); + } + + async resetViewport(): Promise { + await this.robustClick(this.resetView); + } + + async search(name: string): Promise { + await this.searchInput.fill(name); + } + + async verifyWorkloadVisible(name: string): Promise { + await this.search(name); + await expect(this.highlightedNode).toBeVisible({ timeout: 60_000 }); + } + + async rightClickOnKnativeService(serviceName: string): Promise { + await this.switchToGraphView(); + await this.fitScreen(); + const serviceLabel = this.page + .locator('.odc-knative-service__label') + .filter({ hasText: serviceName }) + .first(); + // eslint-disable-next-line playwright/no-force-option + await serviceLabel.click({ button: 'right', force: true, timeout: 30_000 }); + } + + async rightClickOnKnativeRevision(serviceName: string): Promise { + await this.switchToGraphView(); + await this.fitScreen(); + // Target the revision node — revision labels are stable (no controller reconciliation conflict) + const revisionNode = this.page + .locator('[data-type="knative-revision"]') + .filter({ hasText: serviceName }) + .first(); + // eslint-disable-next-line playwright/no-force-option + await revisionNode.click({ button: 'right', force: true, timeout: 30_000 }); + } + + async selectContextMenuAction(action: string): Promise { + const menuItem = this.page.locator( + `[data-test="${action}"], [data-test-action="${action}"]`, + ); + await this.robustClick(menuItem.first(), { timeout: 10_000 }); + } + + async rightClickAndSelectAction(serviceName: string, action: string): Promise { + await this.rightClickOnKnativeService(serviceName); + await this.selectContextMenuAction(action); + } + + async rightClickRevisionAndSelectAction(serviceName: string, action: string): Promise { + await this.rightClickOnKnativeRevision(serviceName); + await this.selectContextMenuAction(action); + } + + async clickOnKnativeService(serviceName: string): Promise { + await this.switchToGraphView(); + await this.fitScreen(); + const serviceLabel = this.knativeServiceNode + .locator('.odc-base-node__label') + .filter({ hasText: serviceName }); + // eslint-disable-next-line playwright/no-force-option + await serviceLabel.click({ force: true, timeout: 30_000 }); + } + + async clickOnApplicationGrouping(appName: string): Promise { + const appNode = this.page.locator(`[data-id="group:${appName}"]`).first(); + // eslint-disable-next-line playwright/no-force-option + await appNode.click({ force: true, timeout: 30_000 }); + } + + async verifySidePaneOpen(): Promise { + await expect(this.sidePane).toBeVisible({ timeout: 30_000 }); + } + + async selectSidePaneTab(tabName: string): Promise { + const tab = this.sidePane.getByRole('tab', { name: tabName }); + await this.robustClick(tab); + await this.waitForLoadingComplete(); + } + + async closeSidePane(): Promise { + if ((await this.sidePaneClose.count()) > 0) { + await this.robustClick(this.sidePaneClose); + } + } + + getSidePane(): Locator { + return this.sidePane; + } + + getKnativeServiceNode(): Locator { + return this.knativeServiceNode; + } + + getEditAnnotationsLink(): Locator { + return this.editAnnotationsLink; + } + + getModalTitle(): Locator { + return this.modalTitle; + } + + getModalCancel(): Locator { + return this.modalCancel; + } + + async clickOnTopologyNode(nodeName: string): Promise { + await this.switchToGraphView(); + await this.fitScreen(); + const nodeLabel = this.page + .locator('g[class$="topology__node__label"] text') + .filter({ hasText: nodeName }) + .first(); + // eslint-disable-next-line playwright/no-force-option + await nodeLabel.click({ force: true, timeout: 30_000 }); + } + + async rightClickOnTopologyNode(nodeName: string): Promise { + await this.switchToGraphView(); + await this.fitScreen(); + const nodeLabel = this.page + .locator('g[class$="topology__node__label"] text') + .filter({ hasText: nodeName }) + .first(); + // eslint-disable-next-line playwright/no-force-option + await nodeLabel.click({ button: 'right', force: true, timeout: 30_000 }); + } + + async rightClickNodeAndSelectAction(nodeName: string, action: string): Promise { + await this.rightClickOnTopologyNode(nodeName); + await this.selectContextMenuAction(action); + } + + async selectSidebarAction(actionName: string): Promise { + const actionsButton = this.sidePane.locator( + '[data-test="actions-menu-button"], [data-test-id="actions-menu-button"]', + ); + await actionsButton.first().click({ timeout: 10_000 }); + const actionItem = this.page.locator( + `[data-test="${actionName}"], [data-test-action="${actionName}"]`, + ); + await actionItem.first().click({ timeout: 10_000 }); + } +} diff --git a/frontend/e2e/setup/knative.setup.ts b/frontend/e2e/setup/knative.setup.ts new file mode 100644 index 00000000000..6c3a277c2e4 --- /dev/null +++ b/frontend/e2e/setup/knative.setup.ts @@ -0,0 +1,241 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { test as setup } from '@playwright/test'; +import yaml from 'js-yaml'; + +import KubernetesClient from '../clients/kubernetes-client'; + +const SUBSCRIPTION_YAML = path.resolve( + import.meta.dirname, + '../../packages/knative-plugin/integration-tests/testData/serverlessOperatorSubscription.yaml', +); + +const SERVING_YAML = path.resolve( + import.meta.dirname, + '../../packages/knative-plugin/integration-tests/testData/knative-serving.yaml', +); + +const EVENTING_YAML = path.resolve( + import.meta.dirname, + '../../packages/knative-plugin/integration-tests/testData/knative-eventing.yaml', +); + +setup.describe.configure({ mode: 'serial' }); + +setup('install OpenShift Serverless operator if not present', async ({ }) => { + setup.setTimeout(600_000); + + const k8sClient = new KubernetesClient( + { + clusterUrl: process.env.CLUSTER_URL || '', + username: process.env.OPENSHIFT_USERNAME || 'kubeadmin', + password: process.env.BRIDGE_KUBEADMIN_PASSWORD || '', + }, + process.env.KUBECONFIG, + ); + + // Check if the Serverless operator is already installed + try { + const csvList = await k8sClient.customObjectsApi.listNamespacedCustomObject({ + group: 'operators.coreos.com', + version: 'v1alpha1', + namespace: 'openshift-serverless', + plural: 'clusterserviceversions', + }); + const items = (csvList as { items?: Array<{ status?: { phase?: string } }> }).items || []; + const installed = items.some((csv) => csv.status?.phase === 'Succeeded'); + if (installed) { + // eslint-disable-next-line no-console + console.log('Serverless operator already installed, skipping installation'); + return; + } + } catch { + // Namespace may not exist yet — proceed with installation + } + + // Apply the subscription YAML (namespace + operatorgroup + subscription) + // eslint-disable-next-line no-console + console.log('Installing Serverless operator...'); + const subscriptionYaml = fs.readFileSync(SUBSCRIPTION_YAML, 'utf-8'); + const docs = subscriptionYaml + .split(/^---$/m) + .filter((s) => s.trim()) + .map((s) => yaml.load(s) as Record); + + for (const doc of docs) { + const kind = doc.kind as string; + const metadata = doc.metadata as { name: string; namespace?: string }; + const apiVersion = doc.apiVersion as string; + + // Remove startingCSV to install the latest available version + if (kind === 'Subscription') { + const spec = doc.spec as Record; + delete spec.startingCSV; + } + + try { + if (kind === 'Namespace') { + await k8sClient.createNamespace(metadata.name); + } else { + const [group, version] = apiVersion.includes('/') + ? apiVersion.split('/') + : ['', apiVersion]; + const plural = kind === 'OperatorGroup' ? 'operatorgroups' : 'subscriptions'; + await k8sClient.createCustomResource( + group, + version, + metadata.namespace || 'openshift-serverless', + plural, + doc, + ); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes('already exists') && !msg.includes('AlreadyExists')) { + throw err; + } + } + } + + // Wait for the CSV to reach Succeeded phase + // eslint-disable-next-line no-console + console.log('Waiting for Serverless operator CSV to succeed...'); + const startTime = Date.now(); + const csvTimeout = 480_000; + let csvSucceeded = false; + while (Date.now() - startTime < csvTimeout) { + try { + const csvList = await k8sClient.customObjectsApi.listNamespacedCustomObject({ + group: 'operators.coreos.com', + version: 'v1alpha1', + namespace: 'openshift-serverless', + plural: 'clusterserviceversions', + }); + const items = (csvList as { items?: Array<{ status?: { phase?: string } }> }).items || []; + if (items.some((csv) => csv.status?.phase === 'Succeeded')) { + // eslint-disable-next-line no-console + console.log('Serverless operator installed successfully'); + csvSucceeded = true; + break; + } + } catch { + // CSV not ready yet + } + await new Promise((r) => setTimeout(r, 10_000)); + } + if (!csvSucceeded) { + throw new Error('Serverless operator CSV did not reach Succeeded phase'); + } +}); + +setup('create KnativeServing and KnativeEventing instances', async ({ }) => { + setup.setTimeout(600_000); + + const k8sClient = new KubernetesClient( + { + clusterUrl: process.env.CLUSTER_URL || '', + username: process.env.OPENSHIFT_USERNAME || 'kubeadmin', + password: process.env.BRIDGE_KUBEADMIN_PASSWORD || '', + }, + process.env.KUBECONFIG, + ); + + // Apply KnativeServing + const servingYaml = fs.readFileSync(SERVING_YAML, 'utf-8'); + const servingDocs = servingYaml + .split(/^---$/m) + .filter((s) => s.trim()) + .map((s) => yaml.load(s) as Record); + + for (const doc of servingDocs) { + const kind = doc.kind as string; + const metadata = doc.metadata as { name: string; namespace?: string }; + try { + if (kind === 'Namespace') { + await k8sClient.createNamespace(metadata.name); + } else { + await k8sClient.customObjectsApi.createNamespacedCustomObject({ + group: 'operator.knative.dev', + version: 'v1beta1', + namespace: metadata.namespace || 'knative-serving', + plural: 'knativeservings', + body: doc, + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes('already exists') && !msg.includes('AlreadyExists')) { + throw err; + } + } + } + + // Apply KnativeEventing + const eventingYaml = fs.readFileSync(EVENTING_YAML, 'utf-8'); + const eventingDocs = eventingYaml + .split(/^---$/m) + .filter((s) => s.trim()) + .map((s) => yaml.load(s) as Record); + + for (const doc of eventingDocs) { + const kind = doc.kind as string; + const metadata = doc.metadata as { name: string; namespace?: string }; + try { + if (kind === 'Namespace') { + await k8sClient.createNamespace(metadata.name); + } else { + await k8sClient.customObjectsApi.createNamespacedCustomObject({ + group: 'operator.knative.dev', + version: 'v1beta1', + namespace: metadata.namespace || 'knative-eventing', + plural: 'knativeeventings', + body: doc, + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes('already exists') && !msg.includes('AlreadyExists')) { + throw err; + } + } + } + + // Wait for Serving and Eventing to be Ready + // eslint-disable-next-line no-console + console.log('Waiting for KnativeServing and KnativeEventing to be ready...'); + const waitForReady = async ( + plural: string, + namespace: string, + name: string, + timeoutMs = 480_000, + ) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const resource = (await k8sClient.customObjectsApi.getNamespacedCustomObject({ + group: 'operator.knative.dev', + version: 'v1beta1', + namespace, + plural, + name, + })) as { status?: { conditions?: Array<{ type: string; status: string }> } }; + const ready = resource.status?.conditions?.find( + (c: { type: string }) => c.type === 'Ready', + ); + if (ready?.status === 'True') { + // eslint-disable-next-line no-console + console.log(`${name} in ${namespace} is Ready`); + return; + } + } catch { + // Not ready yet + } + await new Promise((r) => setTimeout(r, 10_000)); + } + throw new Error(`${name} in ${namespace} not ready after ${timeoutMs}ms`); + }; + + await waitForReady('knativeservings', 'knative-serving', 'knative-serving'); + await waitForReady('knativeeventings', 'knative-eventing', 'knative-eventing'); +}); diff --git a/frontend/e2e/tests/knative/serverless/knative-ci.spec.ts b/frontend/e2e/tests/knative/serverless/knative-ci.spec.ts new file mode 100644 index 00000000000..e8ff8ae35ba --- /dev/null +++ b/frontend/e2e/tests/knative/serverless/knative-ci.spec.ts @@ -0,0 +1,442 @@ +import { test, expect } from '../../../fixtures'; +import { AddFlowPage } from '../../../pages/knative/add-flow-page'; +import { AdminEventingPage } from '../../../pages/knative/admin-eventing-page'; +import { TopologyKnativePage } from '../../../pages/knative/topology-knative-page'; +import KubernetesClient from '../../../clients/kubernetes-client'; + +const SERVICE_NAME = 'kn-service'; +const GIT_URL = 'https://github.com/sclorg/nodejs-ex.git'; + +test.describe( + 'Knative CI smoke tests', + { tag: ['@smoke', '@regression'] }, + () => { + test.describe.configure({ mode: 'serial' }); + + let k8sClient: KubernetesClient; + let namespace: string; + + test.beforeAll(async ({ k8sClient: client }) => { + k8sClient = client; + namespace = `aut-knative-ci-${Date.now()}`; + await k8sClient.createNamespace(namespace); + }); + + test.afterAll(async () => { + await k8sClient.deleteNamespace(namespace); + }); + + test('KN-05-TC04: Create knative workload from Git', async ({ page }) => { + const addFlowPage = new AddFlowPage(page); + const topologyPage = new TopologyKnativePage(page); + + await test.step('Navigate to Add page and import from Git', async () => { + await addFlowPage.navigateToAddPage(namespace); + await addFlowPage.clickImportFromGitCard(); + }); + + await test.step('Enter Git URL and configure workload', async () => { + await addFlowPage.enterGitUrl(GIT_URL); + // Force Builder Image strategy so the Resources dropdown is always available + const builderImageStrategy = page.getByTestId('import-strategy Builder Image'); + if ((await builderImageStrategy.count()) > 0) { + await builderImageStrategy.click(); + const nodeJsImage = page.locator('.odc-selector-card').filter({ hasText: 'Node.js' }); + if ((await nodeJsImage.count()) > 0) { + await nodeJsImage.first().click(); + } + } + await addFlowPage.enterComponentName(SERVICE_NAME); + await addFlowPage.selectServerlessDeployment(); + }); + + await test.step('Submit and verify topology', async () => { + await addFlowPage.clickCreate(); + await expect(page).toHaveURL(/topology/, { timeout: 30_000 }); + }); + + await test.step('Verify workload and revision visible', async () => { + await topologyPage.verifyWorkloadVisible(SERVICE_NAME); + await expect( + page.locator('[data-type="knative-revision"]').first(), + ).toBeAttached({ timeout: 120_000 }); + }); + }); + + test('KN-02-TC02: Edit labels modal details', async ({ page }) => { + const topologyPage = new TopologyKnativePage(page); + + await test.step('Right-click service and select Edit labels', async () => { + await topologyPage.navigateToTopology(namespace); + await topologyPage.rightClickAndSelectAction(SERVICE_NAME, 'Edit labels'); + }); + + await test.step('Verify modal with save and cancel buttons', async () => { + await expect(topologyPage.getModalTitle()).toContainText('Edit labels'); + await expect(page.locator('[data-test="confirm-action"]')).toBeVisible(); + await expect(topologyPage.getModalCancel()).toBeVisible(); + await topologyPage.getModalCancel().click(); + }); + }); + + test('KN-02-TC17: Edit Annotation modal details', async ({ page }) => { + const topologyPage = new TopologyKnativePage(page); + + await test.step('Right-click service and select Edit annotations', async () => { + await topologyPage.navigateToTopology(namespace); + await topologyPage.rightClickAndSelectAction(SERVICE_NAME, 'Edit annotations'); + }); + + await test.step('Verify modal content', async () => { + await expect(topologyPage.getModalTitle()).toContainText('Edit annotations'); + const nameFields = page.getByTestId('pairs-list-name'); + const valueFields = page.getByTestId('pairs-list-value'); + await expect(nameFields.first()).toBeVisible(); + await expect(valueFields.first()).toBeVisible(); + await expect(page.getByTestId('add-button')).toBeVisible(); + await expect(page.locator('[data-test="confirm-action"]')).toBeVisible(); + await expect(topologyPage.getModalCancel()).toBeVisible(); + await topologyPage.getModalCancel().click(); + }); + }); + + test('KA-01-TC01: Create new Event Source via Ping Source', async ({ page }) => { + const eventingPage = new AdminEventingPage(page); + + await test.step('Navigate to Eventing page', async () => { + await eventingPage.navigateToEventing(namespace); + }); + + await test.step('Click Create dropdown and select Event Source', async () => { + await eventingPage.getCreateButton().click(); + await page.locator('[data-test="eventSource"], [data-test-dropdown-menu="eventSource"]').first().click(); + }); + + await test.step('Select Ping Source', async () => { + const pingSourceCard = page.getByTestId('EventSource-PingSource'); + await pingSourceCard.click({ timeout: 30_000 }); + const createBtn = page.locator('a[role="button"]').filter({ hasText: /Create/i }); + await createBtn.click({ timeout: 10_000 }); + }); + + await test.step('Fill Ping Source form', async () => { + await page + .locator('#form-input-formData-data-PingSource-data-field') + .fill('Message'); + await page + .locator('#form-input-formData-data-PingSource-schedule-field') + .fill('* * * * *'); + const resourceDropdown = page.locator('#form-ns-dropdown-formData-sink-key-field'); + await resourceDropdown.click(); + const resourceItem = page.getByTestId('console-select-item').filter({ hasText: SERVICE_NAME }); + await resourceItem.click({ timeout: 30_000 }); + }); + + await test.step('Submit and verify redirect', async () => { + await page.getByTestId('save-changes').click(); + await expect(page).toHaveURL(/topology/, { timeout: 30_000 }); + }); + }); + + test('KA-01-TC02: Create new Channel via default channel type', async ({ page }) => { + const eventingPage = new AdminEventingPage(page); + + await test.step('Navigate to Eventing page', async () => { + await eventingPage.navigateToEventing(namespace); + }); + + await test.step('Click Create dropdown and select Channel', async () => { + await eventingPage.getCreateButton().click(); + await page.locator('[data-test="channels"], [data-test-dropdown-menu="channels"]').first().click(); + }); + + await test.step('Select Default Channel and create', async () => { + const typeDropdown = page.locator('#form-dropdown-formData-type-field'); + await typeDropdown.click(); + await page.getByTestId('console-select-item').filter({ hasText: 'Default Channel' }).click(); + await page.getByTestId('save-changes').click(); + }); + + await test.step('Verify redirect to Topology', async () => { + await expect(page).toHaveURL(/topology/, { timeout: 30_000 }); + }); + }); + + test('KE-05-TC01: Create Broker using Form view', async ({ page }) => { + const eventingPage = new AdminEventingPage(page); + + await test.step('Navigate to Eventing page', async () => { + await eventingPage.navigateToEventing(namespace); + }); + + await test.step('Click Create dropdown and select Broker', async () => { + await eventingPage.getCreateButton().click(); + await page.locator('[data-test="brokers"], [data-test-dropdown-menu="brokers"]').first().click(); + }); + + await test.step('Select Form view, enter name and create', async () => { + await page.locator('#form-radiobutton-editorType-form-field').click(); + const nameField = page.locator('[data-test="application-form-app-name"], [data-test-id="application-form-app-name"]').first(); + await nameField.clear(); + await nameField.fill('default-broker'); + await page.getByTestId('save-changes').click(); + }); + + await test.step('Verify redirect to Topology', async () => { + await expect(page).toHaveURL(/topology/, { timeout: 30_000 }); + }); + }); + + test('Add Subscription to channel', async ({ page }) => { + const topologyPage = new TopologyKnativePage(page); + + await test.step('Navigate to topology and right-click channel', async () => { + await topologyPage.navigateToTopology(namespace); + await topologyPage.rightClickNodeAndSelectAction('channel', 'Add Subscription'); + }); + + await test.step('Fill subscription form', async () => { + await expect(page.getByText('Add Subscription', { exact: true })).toBeVisible({ timeout: 10_000 }); + const nameField = page.locator('#form-input-formData-metadata-name-field'); + await nameField.clear(); + await nameField.fill('channel-subscrip'); + const subscriberDropdown = page.locator('[id$="subscriber-ref-name-field"]'); + await subscriberDropdown.click(); + await page.getByTestId('console-select-item').filter({ hasText: SERVICE_NAME }).click(); + }); + + await test.step('Submit and verify connection', async () => { + await page.getByRole('button', { name: 'Add', exact: true }).click(); + await expect(page.getByText('Add Subscription', { exact: true })).not.toBeAttached({ timeout: 10_000 }); + }); + + await test.step('Verify subscriber in channel sidebar', async () => { + await topologyPage.clickOnTopologyNode('channel'); + await topologyPage.verifySidePaneOpen(); + await expect(topologyPage.getSidePane()).toContainText(SERVICE_NAME); + await topologyPage.closeSidePane(); + }); + }); + + test('KN-02-TC08: Update service to new application group', async ({ page }) => { + const topologyPage = new TopologyKnativePage(page); + + await test.step('Right-click and select Edit application grouping', async () => { + await topologyPage.navigateToTopology(namespace); + await topologyPage.rightClickAndSelectAction(SERVICE_NAME, 'Edit application grouping'); + }); + + await test.step('Create new application group', async () => { + await expect(topologyPage.getModalTitle()).toContainText('Edit application grouping'); + // If service has no app group, modal shows a text input directly (no dropdown) + const appDropdown = page.locator('#form-dropdown-application-name-field'); + const appInput = page.getByTestId('application-form-app-input'); + if ((await appDropdown.count()) > 0) { + await appDropdown.click(); + await page.locator('[data-test="#CREATE_APPLICATION_KEY#"], [data-test-dropdown-menu="#CREATE_APPLICATION_KEY#"]').first().click(); + } + await appInput.clear(); + await appInput.fill('openshift-app'); + await page.locator('button[type=submit]').click(); + await expect(topologyPage.getModalCancel()).not.toBeAttached({ timeout: 10_000 }); + }); + + await test.step('Verify service is in new application group', async () => { + await topologyPage.search('openshift-app'); + await expect(page.locator('.is-filtered').first()).toBeVisible({ timeout: 30_000 }); + await topologyPage.clickOnApplicationGrouping('openshift-app'); + await topologyPage.verifySidePaneOpen(); + await expect(topologyPage.getSidePane()).toContainText(SERVICE_NAME); + }); + }); + + test('KN-01-TC12: Delete Revision not possible for single revision', async ({ page }) => { + const topologyPage = new TopologyKnativePage(page); + + await test.step('Right-click revision and select Delete Revision', async () => { + await topologyPage.navigateToTopology(namespace); + await topologyPage.rightClickRevisionAndSelectAction(SERVICE_NAME, 'Delete Revision'); + }); + + await test.step('Verify unable-to-delete modal', async () => { + await expect(page.getByText('Unable to delete Revision')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('You cannot delete the last Revision for the Service.')).toBeVisible(); + await page.getByRole('button', { name: 'OK', exact: true }).click(); + }); + }); + + test('Create Revision for existing knative Service', async ({ page }) => { + const topologyPage = new TopologyKnativePage(page); + + await test.step('Create second revision via API', async () => { + const svc = await k8sClient.customObjectsApi.getNamespacedCustomObject({ + group: 'serving.knative.dev', + version: 'v1', + namespace, + plural: 'services', + name: SERVICE_NAME, + }); + const body = svc as Record; + const spec = body.spec as Record; + const template = spec.template as Record; + const templateSpec = template.spec as Record; + const containers = templateSpec.containers as Array>; + containers[0].env = [{ name: 'REVISION_TRIGGER', value: 'v2' }]; + await k8sClient.customObjectsApi.replaceNamespacedCustomObject({ + group: 'serving.knative.dev', + version: 'v1', + namespace, + plural: 'services', + name: SERVICE_NAME, + body, + }); + }); + + await test.step('Verify multiple revisions in sidebar', async () => { + await topologyPage.navigateToTopology(namespace); + await topologyPage.clickOnKnativeService(SERVICE_NAME); + await topologyPage.verifySidePaneOpen(); + await topologyPage.selectSidePaneTab('Resources'); + await expect(page.locator('.revision-overview-list').locator('~ ul li')).toHaveCount(2, { timeout: 60_000 }); + }); + }); + + test('KN-02-TC10: Set traffic distribution >100%', async ({ page }) => { + const topologyPage = new TopologyKnativePage(page); + + await test.step('Open Set traffic distribution modal', async () => { + await topologyPage.navigateToTopology(namespace); + await topologyPage.rightClickAndSelectAction(SERVICE_NAME, 'Set traffic distribution'); + }); + + await test.step('Set traffic >100% and verify error', async () => { + await expect( + page.getByText('Set traffic distribution', { exact: true }), + ).toBeVisible({ timeout: 30_000 }); + await page.getByTestId('add-action').click(); + await page.locator('[id$="percent-field"]').last().clear(); + await page.locator('[id$="percent-field"]').last().fill('50'); + await page.getByTestId('console-select-menu-toggle').nth(1).click(); + await page.getByTestId('console-select-item').first().click(); + await page.locator('button[type=submit]').click(); + await expect(page.locator('div.co-alert div.co-pre-line')).toContainText( + 'Traffic targets sum to 150, want 100', + ); + }); + }); + + test('KN-02-TC11: Set traffic distribution <100%', async ({ page }) => { + const topologyPage = new TopologyKnativePage(page); + + await test.step('Open Set traffic distribution modal', async () => { + await topologyPage.navigateToTopology(namespace); + await topologyPage.rightClickAndSelectAction(SERVICE_NAME, 'Set traffic distribution'); + }); + + await test.step('Set traffic <100% and verify error', async () => { + await expect( + page.getByText('Set traffic distribution', { exact: true }), + ).toBeVisible({ timeout: 30_000 }); + await page.locator('[id$="percent-field"]').first().clear(); + await page.locator('[id$="percent-field"]').first().fill('25'); + await page.getByTestId('add-action').click(); + await page.locator('[id$="percent-field"]').last().clear(); + await page.locator('[id$="percent-field"]').last().fill('50'); + await page.getByTestId('console-select-menu-toggle').nth(1).click(); + await page.getByTestId('console-select-item').first().click(); + await page.locator('button[type=submit]').click(); + await expect(page.locator('div.co-alert div.co-pre-line')).toContainText( + 'Traffic targets sum to 75, want 100', + ); + }); + }); + + test('KE-05-TC11: Delete Broker', async ({ page }) => { + const topologyPage = new TopologyKnativePage(page); + + await test.step('Right-click broker and select Delete Broker', async () => { + await topologyPage.navigateToTopology(namespace); + await topologyPage.rightClickNodeAndSelectAction('default-broker', 'Delete Broker'); + }); + + await test.step('Confirm deletion', async () => { + await expect(topologyPage.getModalTitle()).toContainText('Delete'); + await page.locator('button[type=submit]').click(); + await expect(topologyPage.getModalCancel()).not.toBeAttached({ timeout: 10_000 }); + }); + + await test.step('Verify broker removed', async () => { + await page.reload(); + await page.waitForLoadState('load'); + await topologyPage.search('default-broker'); + await expect(page.locator('.is-filtered')).not.toBeAttached({ timeout: 10_000 }); + }); + }); + + test('KE-06-TC16: Delete Channel', async ({ page }) => { + const topologyPage = new TopologyKnativePage(page); + + await test.step('Right-click channel and select Delete Channel', async () => { + await topologyPage.navigateToTopology(namespace); + await topologyPage.rightClickNodeAndSelectAction('channel', 'Delete Channel'); + }); + + await test.step('Confirm deletion', async () => { + await expect(topologyPage.getModalTitle()).toContainText('Delete'); + await page.locator('button[type=submit]').click(); + await expect(topologyPage.getModalCancel()).not.toBeAttached({ timeout: 10_000 }); + }); + + await test.step('Verify channel removed', async () => { + await page.reload(); + await page.waitForLoadState('load'); + await topologyPage.search('channel'); + await expect(page.locator('.is-filtered')).not.toBeAttached({ timeout: 10_000 }); + }); + }); + + test('KE-01-TC03: Delete event source', async ({ page }) => { + const topologyPage = new TopologyKnativePage(page); + + await test.step('Right-click event source and select Delete PingSource', async () => { + await topologyPage.navigateToTopology(namespace); + await topologyPage.rightClickNodeAndSelectAction('ping-source', 'Delete PingSource'); + }); + + await test.step('Confirm deletion', async () => { + await expect(topologyPage.getModalTitle()).toContainText('Delete'); + await page.locator('button[type=submit]').click(); + await expect(topologyPage.getModalCancel()).not.toBeAttached({ timeout: 10_000 }); + }); + + await test.step('Verify event source removed', async () => { + await page.reload(); + await page.waitForLoadState('load'); + await topologyPage.search('ping-source'); + await expect(page.locator('.is-filtered')).not.toBeAttached({ timeout: 30_000 }); + }); + }); + + test('KN-02-TC16: Delete service', async ({ page }) => { + const topologyPage = new TopologyKnativePage(page); + + await test.step('Right-click service and select Delete Service', async () => { + await topologyPage.navigateToTopology(namespace); + await topologyPage.rightClickAndSelectAction(SERVICE_NAME, 'Delete Service'); + }); + + await test.step('Confirm deletion', async () => { + await expect(topologyPage.getModalTitle()).toContainText('Delete Service?'); + await page.locator('button[type=submit]').click(); + await expect(topologyPage.getModalCancel()).not.toBeAttached(); + }); + + await test.step('Verify service removed', async () => { + await page.reload(); + await expect( + page.locator('[data-type="knative-service"]'), + ).not.toBeAttached({ timeout: 30_000 }); + }); + }); + }, +); diff --git a/frontend/integration-tests/test-cypress.sh b/frontend/integration-tests/test-cypress.sh index 866cc9bc6eb..802836556b7 100755 --- a/frontend/integration-tests/test-cypress.sh +++ b/frontend/integration-tests/test-cypress.sh @@ -86,7 +86,6 @@ if [ -n "${headless-}" ] && [ -z "${pkg-}" ]; then yarn run test-cypress-dev-console-headless yarn run test-cypress-olm-headless yarn run test-cypress-helm-headless - yarn run test-cypress-knative-headless yarn run test-cypress-topology-headless exit; fi diff --git a/frontend/package.json b/frontend/package.json index 21a6b7718d4..816ecfcf76b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,7 +35,6 @@ "test-cypress-telemetry-nightly": "cd packages/console-telemetry-plugin/integration-tests && yarn run test-cypress-headless-all", "test-cypress-telemetry": "cd packages/console-telemetry-plugin/integration-tests && yarn run test-cypress", "test-cypress-knative": "cd packages/knative-plugin/integration-tests && yarn run test-cypress", - "test-cypress-knative-headless": "cd packages/knative-plugin/integration-tests && yarn run test-cypress-headless", "test-cypress-knative-nightly": "cd packages/knative-plugin/integration-tests && yarn run test-cypress-headless-all", "test-cypress-helm": "cd packages/helm-plugin/integration-tests && yarn run test-cypress", "test-cypress-helm-headless": "cd packages/helm-plugin/integration-tests && yarn run test-cypress-headless", diff --git a/frontend/packages/console-shared/src/components/actions/menu/ActionMenuContent.tsx b/frontend/packages/console-shared/src/components/actions/menu/ActionMenuContent.tsx index 76c1d919d93..834a3c32b95 100644 --- a/frontend/packages/console-shared/src/components/actions/menu/ActionMenuContent.tsx +++ b/frontend/packages/console-shared/src/components/actions/menu/ActionMenuContent.tsx @@ -32,6 +32,7 @@ const SubMenuContent: FC = ({ option, onClick }) => ( diff --git a/frontend/packages/console-shared/src/components/actions/menu/ActionMenuItem.tsx b/frontend/packages/console-shared/src/components/actions/menu/ActionMenuItem.tsx index 474f783969a..b570049b539 100644 --- a/frontend/packages/console-shared/src/components/actions/menu/ActionMenuItem.tsx +++ b/frontend/packages/console-shared/src/components/actions/menu/ActionMenuItem.tsx @@ -67,6 +67,7 @@ const ActionItem: FC = ({ onClick: handleClick, 'data-test': label, 'data-test-action': label, + 'data-test': label, translate: 'no' as 'no', }; diff --git a/frontend/packages/console-shared/src/components/form-utils/ActionGroupWithIcons.tsx b/frontend/packages/console-shared/src/components/form-utils/ActionGroupWithIcons.tsx index bde7415f285..eeed57b2717 100644 --- a/frontend/packages/console-shared/src/components/form-utils/ActionGroupWithIcons.tsx +++ b/frontend/packages/console-shared/src/components/form-utils/ActionGroupWithIcons.tsx @@ -17,6 +17,7 @@ export const ActionGroupWithIcons: FC = ({ type="submit" onClick={onSubmit} variant={ButtonVariant.plain} + data-test="check-icon" data-test-id="check-icon" style={{ padding: '0' }} isDisabled={isDisabled} diff --git a/frontend/packages/console-shared/src/components/form-utils/FormFooter.tsx b/frontend/packages/console-shared/src/components/form-utils/FormFooter.tsx index 416a5d2fba3..bf92785c86e 100644 --- a/frontend/packages/console-shared/src/components/form-utils/FormFooter.tsx +++ b/frontend/packages/console-shared/src/components/form-utils/FormFooter.tsx @@ -69,6 +69,7 @@ export const FormFooter: FC = ({ {handleReset && ( - diff --git a/frontend/packages/topology/src/components/page/TopologyPageToolbar.tsx b/frontend/packages/topology/src/components/page/TopologyPageToolbar.tsx index 272df65f014..f3975b1e651 100644 --- a/frontend/packages/topology/src/components/page/TopologyPageToolbar.tsx +++ b/frontend/packages/topology/src/components/page/TopologyPageToolbar.tsx @@ -72,6 +72,7 @@ const TopologyPageToolbar: FC = observer(function Topo variant="link" aria-label={viewChangeTooltipContent} className="pf-m-plain odc-topology__view-switcher" + data-test="topology-switcher-view" data-test-id="topology-switcher-view" isDisabled={isEmptyModel} onClick={() => diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 46e69bc30cd..3d6c4e6375b 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -120,11 +120,18 @@ export default defineConfig({ testMatch: 'teardown.setup.ts', }, + { + name: 'knative-setup', + testDir: path.resolve(__dirname, 'e2e', 'setup'), + testMatch: 'knative.setup.ts', + dependencies: ['cluster-setup'], + }, + ...packages.map((pkg) => ({ name: pkg, testDir: path.resolve(__dirname, 'e2e', 'tests', pkg), testIgnore: '**/developer/**', - dependencies: ['admin-auth'], + dependencies: pkg === 'knative' ? ['admin-auth', 'knative-setup'] : ['admin-auth'], use: { ...chrome, storageState: adminStorageState, diff --git a/frontend/public/components/factory/list-page.tsx b/frontend/public/components/factory/list-page.tsx index e4eee738c72..da82eed4815 100644 --- a/frontend/public/components/factory/list-page.tsx +++ b/frontend/public/components/factory/list-page.tsx @@ -296,6 +296,7 @@ export const FireMan: FC = (p content: createProps.items[item], 'data-test': `dropdown-menu-${item}`, 'data-test-dropdown-menu': item, + 'data-test': item, }))} onSelect={(_e, value: string) => runOrNavigate(value)} /> diff --git a/frontend/public/components/filter-toolbar.tsx b/frontend/public/components/filter-toolbar.tsx index 7a67dfd422c..658dfe36c0a 100644 --- a/frontend/public/components/filter-toolbar.tsx +++ b/frontend/public/components/filter-toolbar.tsx @@ -390,7 +390,7 @@ export const FilterToolbar: FC = ({ {acc} ), -
+