From d651a59e90451d74498f2617d01fdc8b7bcc3cb9 Mon Sep 17 00:00:00 2001 From: "Per G. da Silva" Date: Thu, 2 Jul 2026 12:21:24 +0200 Subject: [PATCH] NO-ISSUE: Add Playwright e2e test for operator lifecycle metadata UI Adds an e2e test that verifies the lifecycle metadata columns (Cluster Compatibility and Support Phase) on the Installed Operators page using mocked lifecycle API responses. The test is automatically skipped on clusters without tech preview enabled. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/e2e/mocks/operator-lifecycle.ts | 86 +++++++ .../olm/operator-lifecycle-metadata.spec.ts | 213 ++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 frontend/e2e/mocks/operator-lifecycle.ts create mode 100644 frontend/e2e/tests/olm/operator-lifecycle-metadata.spec.ts diff --git a/frontend/e2e/mocks/operator-lifecycle.ts b/frontend/e2e/mocks/operator-lifecycle.ts new file mode 100644 index 00000000000..e24696e64fd --- /dev/null +++ b/frontend/e2e/mocks/operator-lifecycle.ts @@ -0,0 +1,86 @@ +export type LifecycleData = { + package: string; + schema: string; + versions?: { + name: string; + platformCompatibility?: { name: string; versions: string[] }[]; + phases?: { name: string; startDate: string; endDate: string }[]; + }[]; +}; + +const LIFECYCLE_SCHEMA = 'io.openshift.operators.lifecycles.v1alpha1'; + +const toDateStr = (d: Date): string => d.toISOString().slice(0, 10); + +const activePhases = (): { name: string; startDate: string; endDate: string }[] => { + const now = new Date(); + const maintenanceStart = new Date(now.getFullYear() - 1, 0, 1); + const maintenanceEnd = new Date(now.getFullYear() + 1, 5, 30); + const extendedStart = new Date(maintenanceEnd); + extendedStart.setDate(extendedStart.getDate() + 1); + const extendedEnd = new Date(now.getFullYear() + 3, 11, 31); + return [ + { name: 'Maintenance support', startDate: toDateStr(maintenanceStart), endDate: toDateStr(maintenanceEnd) }, + { name: 'Extended life cycle support', startDate: toDateStr(extendedStart), endDate: toDateStr(extendedEnd) }, + ]; +}; + +const expiredPhases = (): { name: string; startDate: string; endDate: string }[] => { + const now = new Date(); + const maintenanceStart = new Date(now.getFullYear() - 3, 0, 1); + const maintenanceEnd = new Date(now.getFullYear() - 2, 5, 30); + const extendedStart = new Date(maintenanceEnd); + extendedStart.setDate(extendedStart.getDate() + 1); + const extendedEnd = new Date(now.getFullYear() - 1, 11, 31); + return [ + { name: 'Maintenance support', startDate: toDateStr(maintenanceStart), endDate: toDateStr(maintenanceEnd) }, + { name: 'Extended life cycle support', startDate: toDateStr(extendedStart), endDate: toDateStr(extendedEnd) }, + ]; +}; + +export const makeLifecycleActiveAndCompatible = ( + packageName: string, + version: string, + clusterVersion: string, +): LifecycleData => ({ + package: packageName, + schema: LIFECYCLE_SCHEMA, + versions: [ + { + name: version, + platformCompatibility: [{ name: 'openshift', versions: [clusterVersion] }], + phases: activePhases(), + }, + ], +}); + +export const makeLifecycleSelfSupport = ( + packageName: string, + version: string, + clusterVersion: string, +): LifecycleData => ({ + package: packageName, + schema: LIFECYCLE_SCHEMA, + versions: [ + { + name: version, + platformCompatibility: [{ name: 'openshift', versions: [clusterVersion] }], + phases: expiredPhases(), + }, + ], +}); + +export const makeLifecycleIncompatible = ( + packageName: string, + version: string, +): LifecycleData => ({ + package: packageName, + schema: LIFECYCLE_SCHEMA, + versions: [ + { + name: version, + platformCompatibility: [{ name: 'openshift', versions: ['4.99'] }], + phases: activePhases(), + }, + ], +}); diff --git a/frontend/e2e/tests/olm/operator-lifecycle-metadata.spec.ts b/frontend/e2e/tests/olm/operator-lifecycle-metadata.spec.ts new file mode 100644 index 00000000000..1fa87e56f99 --- /dev/null +++ b/frontend/e2e/tests/olm/operator-lifecycle-metadata.spec.ts @@ -0,0 +1,213 @@ +import { test, expect } from '../../fixtures'; +import type KubernetesClient from '../../clients/kubernetes-client'; +import type { LifecycleData } from '../../mocks/operator-lifecycle'; +import { + makeLifecycleActiveAndCompatible, + makeLifecycleSelfSupport, + makeLifecycleIncompatible, +} from '../../mocks/operator-lifecycle'; + +const OLM_GROUP = 'operators.coreos.com'; +const OLM_VERSION = 'v1alpha1'; +const OPERATOR_NAMESPACE = 'openshift-operators'; +const PACKAGE_NAME = 'web-terminal'; +const CSV_NAME_PREFIX = `${PACKAGE_NAME}.v`; +const CATALOG_SOURCE = 'redhat-operators'; +const CATALOG_NAMESPACE = 'openshift-marketplace'; +const LIFECYCLE_URL_PATTERN = '**/api/olm/lifecycle/**'; +const INSTALLED_OPERATORS_URL = `/k8s/ns/${OPERATOR_NAMESPACE}/${OLM_GROUP}~${OLM_VERSION}~ClusterServiceVersion`; + +const INSTALL_TIMEOUT = 300_000; +const POLL_INTERVAL = 10_000; + +type CSVResource = { + metadata?: { name?: string }; + spec?: { version?: string; displayName?: string }; + status?: { phase?: string }; +}; + +const webTerminalSubscription = { + apiVersion: `${OLM_GROUP}/${OLM_VERSION}`, + kind: 'Subscription', + metadata: { + name: PACKAGE_NAME, + namespace: OPERATOR_NAMESPACE, + }, + spec: { + channel: 'fast', + name: PACKAGE_NAME, + source: CATALOG_SOURCE, + sourceNamespace: CATALOG_NAMESPACE, + installPlanApproval: 'Automatic', + }, +}; + +test.describe('Operator lifecycle metadata', { tag: ['@admin'] }, () => { + let k8sClient: KubernetesClient; + let operatorVersion: string; + let operatorDisplayName: string; + + test.beforeAll(async ({ k8sClient: client }) => { + test.setTimeout(INSTALL_TIMEOUT + 60_000); + k8sClient = client; + + try { + await k8sClient.createCustomResource( + OLM_GROUP, + OLM_VERSION, + OPERATOR_NAMESPACE, + 'subscriptions', + webTerminalSubscription, + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes('409') && !msg.includes('already exists')) { + throw err; + } + } + + const deadline = Date.now() + INSTALL_TIMEOUT; + let csv: CSVResource | undefined; + while (Date.now() < deadline) { + const csvs = (await k8sClient.listCustomResources( + OLM_GROUP, + OLM_VERSION, + OPERATOR_NAMESPACE, + 'clusterserviceversions', + )) as CSVResource[]; + + csv = csvs.find( + (c) => + c.metadata?.name?.startsWith(CSV_NAME_PREFIX) && + c.status?.phase === 'Succeeded', + ); + if (csv) break; + await new Promise((r) => setTimeout(r, POLL_INTERVAL)); + } + + if (!csv) { + throw new Error( + `Timed out waiting for ${PACKAGE_NAME} CSV to reach Succeeded phase`, + ); + } + + operatorVersion = csv.spec?.version ?? ''; + operatorDisplayName = csv.spec?.displayName ?? PACKAGE_NAME; + }); + + test.afterAll(async () => { + if (!k8sClient) return; + + try { + await k8sClient.deleteCustomResource( + OLM_GROUP, + OLM_VERSION, + OPERATOR_NAMESPACE, + 'subscriptions', + PACKAGE_NAME, + ); + } catch { + // Ignore cleanup errors + } + + try { + const csvs = (await k8sClient.listCustomResources( + OLM_GROUP, + OLM_VERSION, + OPERATOR_NAMESPACE, + 'clusterserviceversions', + )) as CSVResource[]; + for (const csv of csvs) { + if (csv.metadata?.name?.startsWith(CSV_NAME_PREFIX)) { + await k8sClient.deleteCustomResource( + OLM_GROUP, + OLM_VERSION, + OPERATOR_NAMESPACE, + 'clusterserviceversions', + csv.metadata.name, + ); + } + } + } catch { + // Ignore cleanup errors + } + }); + + test('displays lifecycle metadata columns for installed operator', async ({ page }) => { + let activeLifecycleData: LifecycleData | null = null; + + // Register the route before navigation so the first page load is intercepted. + // The handler reads from a mutable reference, updated between steps. + await page.route(LIFECYCLE_URL_PATTERN, async (route) => { + if (activeLifecycleData) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(activeLifecycleData), + }); + } else { + await route.abort(); + } + }); + + await page.goto(INSTALLED_OPERATORS_URL); + const serverFlags = await page.evaluate( + () => (window as any).SERVER_FLAGS ?? {}, + ); + test.skip(!serverFlags.techPreview, 'Lifecycle metadata columns require tech preview'); + const releaseVersion: string = serverFlags.releaseVersion ?? ''; + const versionMatch = releaseVersion.match(/^(\d+\.\d+)/); + const clusterMinorVersion = versionMatch ? versionMatch[1] : '4.18'; + + const operatorRow = page + .locator('tr') + .filter({ has: page.locator(`[data-test-operator-row="${operatorDisplayName}"]`) }); + + await test.step('Active support phase and compatible cluster', async () => { + activeLifecycleData = makeLifecycleActiveAndCompatible( + PACKAGE_NAME, + operatorVersion, + clusterMinorVersion, + ); + + await page.reload(); + await expect(operatorRow).toBeVisible({ timeout: 30_000 }); + + // The lifecycle feature flag loads asynchronously via a lazy code-ref, + // so the columns may take ~20s to appear after the table renders. + await expect( + operatorRow.locator('[data-test="cluster-compatibility-compatible"]'), + ).toContainText('Compatible', { timeout: 30_000 }); + + await expect( + operatorRow.locator('[data-test="support-phase-badge"]'), + ).toContainText('Maintenance support'); + }); + + await test.step('Self-support when all phases expired', async () => { + activeLifecycleData = makeLifecycleSelfSupport( + PACKAGE_NAME, + operatorVersion, + clusterMinorVersion, + ); + + await page.reload(); + await expect(operatorRow).toBeVisible({ timeout: 30_000 }); + + await expect( + operatorRow.locator('[data-test="support-phase-self-support"]'), + ).toContainText('Self-support', { timeout: 30_000 }); + }); + + await test.step('Incompatible when cluster version not in compatibility list', async () => { + activeLifecycleData = makeLifecycleIncompatible(PACKAGE_NAME, operatorVersion); + + await page.reload(); + await expect(operatorRow).toBeVisible({ timeout: 30_000 }); + + await expect( + operatorRow.locator('[data-test="cluster-compatibility-incompatible"]'), + ).toContainText('Incompatible', { timeout: 30_000 }); + }); + }); +});