From f4d398f4d98a55b506ea0a0ab75177e5db2375d5 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Tue, 23 Jun 2026 11:29:58 -0400 Subject: [PATCH] CONSOLE-5283: Migrate dashboard Cypress tests to Playwright Migrate cluster-dashboard.cy.ts (8 tests) and project-dashboard.cy.ts (8 tests) to Playwright, completing the CONSOLE-5283 story. - Add data-test attributes alongside existing data-test-id on dashboard card components (details, status, inventory, utilization, launcher, resource-quotas, duration-select) for both cluster and project dashboards - Create ProjectDashboardPage page object and extend ClusterDashboardPage with locators for all dashboard card elements - Add createResourceQuota/deleteResourceQuota to KubernetesClient and ResourceQuota cleanup handling to the cleanup fixture - Delete original Cypress test files (including previously migrated insights-popup.cy.ts) Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/clients/kubernetes-client.ts | 24 +++ frontend/e2e/fixtures/cleanup-fixture.ts | 3 + frontend/e2e/pages/cluster-dashboard-page.ts | 61 +++++- frontend/e2e/pages/project-dashboard-page.ts | 95 ++++++++++ .../dashboards/cluster-dashboard.spec.ts | 102 ++++++++++ .../dashboards/project-dashboard.spec.ts | 179 ++++++++++++++++++ .../UtilizationDurationDropdown.tsx | 2 +- .../utilization-card/UtilizationItem.tsx | 12 +- .../tests/dashboards/cluster-dashboard.cy.ts | 129 ------------- .../tests/dashboards/insights-popup.cy.ts | 83 -------- .../tests/dashboards/project-dashboard.cy.ts | 161 ---------------- .../cluster-dashboard/details-card.tsx | 2 +- .../cluster-dashboard/inventory-card.tsx | 2 +- .../cluster-dashboard/status-card.tsx | 2 +- .../project-dashboard/details-card.tsx | 2 +- .../project-dashboard/inventory-card.tsx | 2 +- .../project-dashboard/launcher-card.tsx | 2 +- .../project-dashboard/resource-quota-card.tsx | 2 +- .../project-dashboard/status-card.tsx | 2 +- 19 files changed, 482 insertions(+), 385 deletions(-) create mode 100644 frontend/e2e/pages/project-dashboard-page.ts create mode 100644 frontend/e2e/tests/console/dashboards/cluster-dashboard.spec.ts create mode 100644 frontend/e2e/tests/console/dashboards/project-dashboard.spec.ts delete mode 100644 frontend/packages/integration-tests/tests/dashboards/cluster-dashboard.cy.ts delete mode 100644 frontend/packages/integration-tests/tests/dashboards/insights-popup.cy.ts delete mode 100644 frontend/packages/integration-tests/tests/dashboards/project-dashboard.cy.ts diff --git a/frontend/e2e/clients/kubernetes-client.ts b/frontend/e2e/clients/kubernetes-client.ts index 6da506e9dab..9b3bbc49b4f 100644 --- a/frontend/e2e/clients/kubernetes-client.ts +++ b/frontend/e2e/clients/kubernetes-client.ts @@ -664,4 +664,28 @@ export default class KubernetesClient { 2_000, ); } + + async createResourceQuota( + name: string, + namespace: string, + spec: k8s.V1ResourceQuotaSpec, + ): Promise { + await this.k8sApi.createNamespacedResourceQuota({ + namespace, + body: { + metadata: { name, namespace }, + spec, + }, + }); + } + + async deleteResourceQuota(name: string, namespace: string): Promise { + try { + await this.k8sApi.deleteNamespacedResourceQuota({ name, namespace }); + } catch (err) { + if (!isNotFound(err)) { + throw err; + } + } + } } diff --git a/frontend/e2e/fixtures/cleanup-fixture.ts b/frontend/e2e/fixtures/cleanup-fixture.ts index 363df68b4c5..36036fc30d3 100644 --- a/frontend/e2e/fixtures/cleanup-fixture.ts +++ b/frontend/e2e/fixtures/cleanup-fixture.ts @@ -151,6 +151,9 @@ export function createCleanupFixture(testName: string): CleanupFixture { case 'Secret': await client.deleteSecret(resource.name, resource.namespace); break; + case 'ResourceQuota': + await client.deleteResourceQuota(resource.name, resource.namespace); + break; default: console.warn( `[Cleanup] Unhandled core resource type ${resource.type} "${resource.name}" — skipping`, diff --git a/frontend/e2e/pages/cluster-dashboard-page.ts b/frontend/e2e/pages/cluster-dashboard-page.ts index 07fc593bf62..ed6b3156909 100644 --- a/frontend/e2e/pages/cluster-dashboard-page.ts +++ b/frontend/e2e/pages/cluster-dashboard-page.ts @@ -4,7 +4,18 @@ import { expect } from '@playwright/test'; import BasePage from './base-page'; export class ClusterDashboardPage extends BasePage { - private readonly statusCard = this.page.locator('[data-test-id="status-card"]'); + private readonly detailsCard = this.page.getByTestId('details-card'); + private readonly statusCard = this.page.getByTestId('status-card'); + private readonly inventoryCard = this.page.getByTestId('inventory-card'); + private readonly utilizationCard = this.page.getByTestId('utilization-card'); + private readonly detailItemTitle = this.page.getByTestId('detail-item-title'); + private readonly detailItemValue = this.page.getByTestId('detail-item-value'); + private readonly viewSettingsLink = this.page.getByTestId('details-card-view-settings'); + private readonly viewAlertsLink = this.page.getByTestId('status-card-view-alerts'); + private readonly resourceInventoryItem = this.page.getByTestId('resource-inventory-item'); + private readonly utilizationItem = this.page.getByTestId('utilization-item'); + private readonly utilizationItemTitle = this.page.getByTestId('utilization-item-title'); + private readonly durationSelect = this.page.getByTestId('duration-select'); private readonly insightsHealthItem = this.page.locator( '[data-item-id="Insights-health-item"]', ); @@ -26,6 +37,54 @@ export class ClusterDashboardPage extends BasePage { }); } + getDetailsCard(): Locator { + return this.detailsCard; + } + + getStatusCard(): Locator { + return this.statusCard; + } + + getInventoryCard(): Locator { + return this.inventoryCard; + } + + getUtilizationCard(): Locator { + return this.utilizationCard; + } + + getDetailItemTitle(): Locator { + return this.detailItemTitle; + } + + getDetailItemValue(): Locator { + return this.detailItemValue; + } + + getViewSettingsLink(): Locator { + return this.viewSettingsLink; + } + + getViewAlertsLink(): Locator { + return this.viewAlertsLink; + } + + getResourceInventoryItem(): Locator { + return this.resourceInventoryItem; + } + + getUtilizationItem(): Locator { + return this.utilizationItem; + } + + getUtilizationItemTitle(): Locator { + return this.utilizationItemTitle; + } + + getDurationSelect(): Locator { + return this.durationSelect; + } + getInsightsHealthItem(): Locator { return this.insightsHealthItem; } diff --git a/frontend/e2e/pages/project-dashboard-page.ts b/frontend/e2e/pages/project-dashboard-page.ts new file mode 100644 index 00000000000..00a70e94501 --- /dev/null +++ b/frontend/e2e/pages/project-dashboard-page.ts @@ -0,0 +1,95 @@ +import type { Locator } from '@playwright/test'; + +import BasePage from './base-page'; + +export class ProjectDashboardPage extends BasePage { + private readonly detailsCard = this.page.getByTestId('details-card'); + private readonly statusCard = this.page.getByTestId('status-card'); + private readonly inventoryCard = this.page.getByTestId('inventory-card'); + private readonly utilizationCard = this.page.getByTestId('utilization-card'); + private readonly launcherCard = this.page.getByTestId('launcher-card'); + private readonly resourceQuotasCard = this.page.getByTestId('resource-quotas-card'); + private readonly detailItemTitle = this.page.getByTestId('detail-item-title'); + private readonly detailItemValue = this.page.getByTestId('detail-item-value'); + private readonly viewAllLink = this.page.getByTestId('details-card-view-all'); + private readonly projectStatus = this.page.getByTestId('project-status'); + private readonly resourceInventoryItem = this.page.getByTestId('resource-inventory-item'); + private readonly utilizationItem = this.page.getByTestId('utilization-item'); + private readonly utilizationItemTitle = this.page.getByTestId('utilization-item-title'); + private readonly durationSelect = this.page.getByTestId('duration-select'); + private readonly launcherItem = this.page.getByTestId('launcher-item'); + private readonly resourceQuotaLink = this.page.getByTestId('resource-quota-link'); + private readonly resourceQuotaGaugeChart = this.page.getByTestId('resource-quota-gauge-chart'); + + async navigateToProject(projectName: string): Promise { + await this.goTo(`/k8s/cluster/projects/${projectName}`); + } + + getDetailsCard(): Locator { + return this.detailsCard; + } + + getStatusCard(): Locator { + return this.statusCard; + } + + getInventoryCard(): Locator { + return this.inventoryCard; + } + + getUtilizationCard(): Locator { + return this.utilizationCard; + } + + getLauncherCard(): Locator { + return this.launcherCard; + } + + getResourceQuotasCard(): Locator { + return this.resourceQuotasCard; + } + + getDetailItemTitle(): Locator { + return this.detailItemTitle; + } + + getDetailItemValue(): Locator { + return this.detailItemValue; + } + + getViewAllLink(): Locator { + return this.viewAllLink; + } + + getProjectStatus(): Locator { + return this.projectStatus; + } + + getResourceInventoryItem(): Locator { + return this.resourceInventoryItem; + } + + getUtilizationItem(): Locator { + return this.utilizationItem; + } + + getUtilizationItemTitle(): Locator { + return this.utilizationItemTitle; + } + + getDurationSelect(): Locator { + return this.durationSelect; + } + + getLauncherItem(): Locator { + return this.launcherItem; + } + + getResourceQuotaLink(): Locator { + return this.resourceQuotaLink; + } + + getResourceQuotaGaugeChart(): Locator { + return this.resourceQuotaGaugeChart; + } +} diff --git a/frontend/e2e/tests/console/dashboards/cluster-dashboard.spec.ts b/frontend/e2e/tests/console/dashboards/cluster-dashboard.spec.ts new file mode 100644 index 00000000000..90a0534ffc4 --- /dev/null +++ b/frontend/e2e/tests/console/dashboards/cluster-dashboard.spec.ts @@ -0,0 +1,102 @@ +import { test, expect } from '../../../fixtures'; +import { ClusterDashboardPage } from '../../../pages/cluster-dashboard-page'; + +test.describe('Cluster Dashboard', { tag: ['@admin', '@smoke'] }, () => { + let dashboard: ClusterDashboardPage; + + test.beforeEach(async ({ page }) => { + dashboard = new ClusterDashboardPage(page); + await dashboard.navigateToDashboard(); + }); + + test.describe('Details Card', () => { + test('has all fields populated', async () => { + await expect(dashboard.getDetailsCard()).toBeVisible(); + + const expectedTitles = [ + 'Cluster API address', + 'Cluster ID', + 'Infrastructure provider', + 'OpenShift version', + 'Service Level Agreement (SLA)', + 'Update channel', + ]; + + await expect(dashboard.getDetailItemTitle()).toHaveCount(expectedTitles.length); + + for (let i = 0; i < expectedTitles.length; i++) { + await expect(dashboard.getDetailItemTitle().nth(i)).toHaveText(expectedTitles[i]); + } + + await expect(dashboard.getDetailItemValue()).toHaveCount(expectedTitles.length); + await expect(dashboard.getDetailItemValue().nth(0)).toContainText('https://'); + await expect(dashboard.getDetailItemValue().nth(1)).toContainText('-'); + await expect(dashboard.getDetailItemValue().nth(2)).not.toBeEmpty(); + await expect(dashboard.getDetailItemValue().nth(3)).toContainText('.'); + await expect(dashboard.getDetailItemValue().nth(4)).not.toBeEmpty(); + await expect(dashboard.getDetailItemValue().nth(5)).not.toBeEmpty(); + }); + + test('has View settings link', async () => { + await expect(dashboard.getViewSettingsLink()).toBeVisible(); + await expect(dashboard.getViewSettingsLink()).toHaveAttribute('href', '/settings/cluster/'); + }); + }); + + test.describe('Status Card', () => { + test('has View alerts link', async () => { + await expect(dashboard.getViewAlertsLink()).toBeVisible(); + await expect(dashboard.getViewAlertsLink()).toHaveAttribute('href', '/monitoring/alerts'); + }); + + test('has health indicators', async () => { + await dashboard.waitForStatusCardLoaded(); + await expect(dashboard.getStatusCard()).toBeVisible(); + + const expectedTitles = ['Cluster', 'Control Plane', 'Operators', 'Dynamic Plugins']; + for (const title of expectedTitles) { + await expect(dashboard.getStatusCard().getByTestId(title).first()).toContainText(title); + } + }); + }); + + test.describe('Inventory Card', () => { + test('has all items', async () => { + await expect(dashboard.getInventoryCard()).toBeVisible(); + + const inventoryItems = [ + { title: 'Node', link: '/k8s/cluster/nodes' }, + { title: 'Pod', link: '/k8s/all-namespaces/pods' }, + { title: 'StorageClass', link: '/k8s/cluster/storageclasses' }, + { title: 'PersistentVolumeClaim', link: '/k8s/all-namespaces/persistentvolumeclaims' }, + ]; + + for (let i = 0; i < inventoryItems.length; i++) { + await expect(dashboard.getResourceInventoryItem().nth(i)).toContainText( + inventoryItems[i].title, + ); + await expect(dashboard.getResourceInventoryItem().nth(i)).toHaveAttribute( + 'href', + inventoryItems[i].link, + ); + } + }); + }); + + test.describe('Utilization Card', () => { + test('has all items', async () => { + await expect(dashboard.getUtilizationCard()).toBeVisible(); + + const utilizationItems = ['CPU', 'Memory', 'Filesystem', 'Network transfer', 'Pod count']; + await expect(dashboard.getUtilizationItem()).toHaveCount(utilizationItems.length); + + for (let i = 0; i < utilizationItems.length; i++) { + await expect(dashboard.getUtilizationItemTitle().nth(i)).toHaveText(utilizationItems[i]); + } + }); + + test('has duration dropdown defaulting to 1 hour', async () => { + await expect(dashboard.getDurationSelect()).toContainText('1 hour'); + }); + }); +}); diff --git a/frontend/e2e/tests/console/dashboards/project-dashboard.spec.ts b/frontend/e2e/tests/console/dashboards/project-dashboard.spec.ts new file mode 100644 index 00000000000..553d728128f --- /dev/null +++ b/frontend/e2e/tests/console/dashboards/project-dashboard.spec.ts @@ -0,0 +1,179 @@ +import { test, expect } from '../../../fixtures'; +import { warmupSPA } from '../../../pages/base-page'; +import { ProjectDashboardPage } from '../../../pages/project-dashboard-page'; + +const namespace = `test-project-dashboard-${Date.now()}`; + +test.describe('Project Dashboard', { tag: ['@admin'] }, () => { + let dashboard: ProjectDashboardPage; + + test.beforeAll(async ({ k8sClient }) => { + await k8sClient.createNamespace(namespace); + }); + + test.beforeEach(async ({ page }) => { + await warmupSPA(page); + dashboard = new ProjectDashboardPage(page); + await dashboard.navigateToProject(namespace); + }); + + test.afterAll(async ({ k8sClient }) => { + await k8sClient.deleteNamespace(namespace); + }); + + test.describe('Details Card', () => { + test('has all fields populated', async () => { + await expect(dashboard.getDetailsCard()).toBeVisible(); + + const expectedTitles = ['Name', 'Requester', 'Labels', 'Description']; + await expect(dashboard.getDetailItemTitle()).toHaveCount(expectedTitles.length); + for (let i = 0; i < expectedTitles.length; i++) { + await expect(dashboard.getDetailItemTitle().nth(i)).toHaveText(expectedTitles[i]); + } + + await expect(dashboard.getDetailItemValue()).toHaveCount(expectedTitles.length); + await expect(dashboard.getDetailItemValue().nth(0)).toHaveText(namespace); + await expect(dashboard.getDetailItemValue().nth(1)).not.toBeEmpty(); + await expect(dashboard.getDetailItemValue().nth(2)).toContainText( + `kubernetes.io/metadata.name=${namespace}`, + ); + await expect(dashboard.getDetailItemValue().nth(3)).toHaveText('No description'); + }); + + test('has View all link that navigates to project details', async ({ page }) => { + await dashboard.getViewAllLink().click(); + await expect(page).toHaveURL(new RegExp(`/k8s/cluster/projects/${namespace}/details`)); + }); + }); + + test.describe('Status Card', () => { + test('has health indicator showing Active', async () => { + await expect(dashboard.getStatusCard()).toBeVisible(); + await expect(dashboard.getProjectStatus()).toHaveText('Active'); + }); + }); + + test.describe('Inventory Card', () => { + test('has all items', async () => { + await expect(dashboard.getInventoryCard()).toBeVisible(); + + const inventoryItems = [ + { kind: 'Deployment', path: `/k8s/ns/${namespace}/deployments` }, + { kind: 'DeploymentConfig', path: `/k8s/ns/${namespace}/deploymentconfigs` }, + { kind: 'StatefulSet', path: `/k8s/ns/${namespace}/statefulsets` }, + { kind: 'Pod', path: `/k8s/ns/${namespace}/pods` }, + { + kind: 'PersistentVolumeClaim', + path: `/k8s/ns/${namespace}/persistentvolumeclaims`, + }, + { kind: 'Service', path: `/k8s/ns/${namespace}/services` }, + { kind: 'Route', path: `/k8s/ns/${namespace}/routes` }, + { kind: 'ConfigMap', path: `/k8s/ns/${namespace}/configmaps` }, + { kind: 'Secret', path: `/k8s/ns/${namespace}/secrets` }, + { + kind: 'VolumeSnapshot', + path: `/k8s/ns/${namespace}/snapshot.storage.k8s.io~v1~VolumeSnapshot`, + }, + ]; + + for (let i = 0; i < inventoryItems.length; i++) { + await expect(dashboard.getResourceInventoryItem().nth(i)).toHaveText( + new RegExp(`^\\d+ ${inventoryItems[i].kind}s?$`), + ); + await expect(dashboard.getResourceInventoryItem().nth(i)).toHaveAttribute( + 'href', + inventoryItems[i].path, + ); + } + }); + }); + + test.describe('Utilization Card', () => { + test('has all items', async () => { + await expect(dashboard.getUtilizationCard()).toBeVisible(); + + const utilizationItems = ['CPU', 'Memory', 'Filesystem', 'Network transfer', 'Pod count']; + await expect(dashboard.getUtilizationItem()).toHaveCount(utilizationItems.length); + for (let i = 0; i < utilizationItems.length; i++) { + await expect(dashboard.getUtilizationItemTitle().nth(i)).toHaveText(utilizationItems[i]); + } + }); + + test('has duration dropdown defaulting to 1 hour', async () => { + await expect(dashboard.getDurationSelect()).toContainText('1 hour'); + }); + }); + + test.describe('Launcher Card', () => { + const consoleLinkName = `link-${namespace}`; + + test('is displayed when ConsoleLink CR exists', async ({ k8sClient, cleanup }) => { + await test.step('Create ConsoleLink', async () => { + await k8sClient.createClusterCustomResource( + 'console.openshift.io', + 'v1', + 'consolelinks', + { + apiVersion: 'console.openshift.io/v1', + kind: 'ConsoleLink', + metadata: { name: consoleLinkName }, + spec: { + href: 'https://www.example.com/', + location: 'NamespaceDashboard', + namespaceDashboard: { namespaces: [namespace] }, + text: 'Namespace Dashboard Link', + }, + }, + ); + cleanup.trackClusterCustomResource( + consoleLinkName, + 'console.openshift.io', + 'v1', + 'consolelinks', + ); + }); + + await test.step('Verify launcher card', async () => { + await dashboard.navigateToProject(namespace); + await expect(dashboard.getLauncherCard()).toBeVisible(); + await expect(dashboard.getLauncherItem()).toContainText('Namespace Dashboard Link'); + await expect(dashboard.getLauncherItem()).toHaveAttribute( + 'href', + 'https://www.example.com/', + ); + }); + }); + }); + + test.describe('Resource Quotas Card', () => { + const quotaName = 'example'; + + test('shows Resource Quotas', async ({ k8sClient, cleanup }) => { + await test.step('Create ResourceQuota', async () => { + await k8sClient.createResourceQuota(quotaName, namespace, { + hard: { + pods: '4', + 'requests.cpu': '1', + 'requests.memory': '1Gi', + 'limits.cpu': '2', + }, + }); + cleanup.track({ + name: quotaName, + namespace, + apiGroup: '', + apiVersion: 'v1', + plural: 'resourcequotas', + type: 'ResourceQuota', + }); + }); + + await test.step('Verify resource quotas card', async () => { + await dashboard.navigateToProject(namespace); + await expect(dashboard.getResourceQuotasCard()).toBeVisible(); + await expect(dashboard.getResourceQuotaLink()).toHaveText(quotaName); + await expect(dashboard.getResourceQuotaGaugeChart()).toHaveCount(4); + }); + }); + }); +}); diff --git a/frontend/packages/console-shared/src/components/dashboard/utilization-card/UtilizationDurationDropdown.tsx b/frontend/packages/console-shared/src/components/dashboard/utilization-card/UtilizationDurationDropdown.tsx index d2e9e1fb239..184d0c07606 100644 --- a/frontend/packages/console-shared/src/components/dashboard/utilization-card/UtilizationDurationDropdown.tsx +++ b/frontend/packages/console-shared/src/components/dashboard/utilization-card/UtilizationDurationDropdown.tsx @@ -29,7 +29,7 @@ export const UtilizationDurationDropdown: FC = ); return ( -
+