From a03d328016bb7324bd36d2ebb7056586586125e1 Mon Sep 17 00:00:00 2001 From: gharden Date: Thu, 28 May 2026 09:33:28 -0400 Subject: [PATCH 1/3] feat(dcm): add Playwright E2E test suite for DCM RHDH plugin 46 browser-based E2E tests covering the full DCM Data Center UI: - Smoke tests: navigation, 6 tabs, seed data verification, API proxy - Providers CRUD: register, edit, search, delete - Policies CRUD: create GLOBAL/USER, toggle, edit, delete - Catalog items: Pet Clinic, create/edit/delete, YAML import - Instances: create dialog, empty state - Regressions: deep link, delete guard, search+pagination, toggle persistence, chip/switch sync, read-only name, whitespace validation, success snackbar, empty table rows, rows-per-page persistence Includes Playwright config, page object (DcmPage), auth fixture, and YAML test data fixtures. Follows rhdh-plugins workspace conventions. Ref: FLPATH-3241, FLPATH-4200 Co-authored-by: Cursor --- workspaces/dcm/package.json | 4 +- .../dcm/packages/app/e2e-tests/app.test.ts | 34 ++ .../app/e2e-tests/dcm-catalog-items.test.ts | 259 ++++++++++ .../app/e2e-tests/dcm-policies.test.ts | 163 ++++++ .../app/e2e-tests/dcm-providers.test.ts | 152 ++++++ .../app/e2e-tests/dcm-regressions.test.ts | 488 ++++++++++++++++++ .../packages/app/e2e-tests/dcm-smoke.test.ts | 158 ++++++ .../packages/app/e2e-tests/fixtures/auth.ts | 31 ++ .../fixtures/dcm/invalid-catalog-item.yaml | 3 + .../fixtures/dcm/valid-catalog-item.yaml | 22 + .../packages/app/e2e-tests/pages/DcmPage.ts | 435 ++++++++++++++++ workspaces/dcm/playwright.config.ts | 76 +++ 12 files changed, 1824 insertions(+), 1 deletion(-) create mode 100644 workspaces/dcm/packages/app/e2e-tests/app.test.ts create mode 100644 workspaces/dcm/packages/app/e2e-tests/dcm-catalog-items.test.ts create mode 100644 workspaces/dcm/packages/app/e2e-tests/dcm-policies.test.ts create mode 100644 workspaces/dcm/packages/app/e2e-tests/dcm-providers.test.ts create mode 100644 workspaces/dcm/packages/app/e2e-tests/dcm-regressions.test.ts create mode 100644 workspaces/dcm/packages/app/e2e-tests/dcm-smoke.test.ts create mode 100644 workspaces/dcm/packages/app/e2e-tests/fixtures/auth.ts create mode 100644 workspaces/dcm/packages/app/e2e-tests/fixtures/dcm/invalid-catalog-item.yaml create mode 100644 workspaces/dcm/packages/app/e2e-tests/fixtures/dcm/valid-catalog-item.yaml create mode 100644 workspaces/dcm/packages/app/e2e-tests/pages/DcmPage.ts create mode 100644 workspaces/dcm/playwright.config.ts diff --git a/workspaces/dcm/package.json b/workspaces/dcm/package.json index 4a859b23d9..0641074e0f 100644 --- a/workspaces/dcm/package.json +++ b/workspaces/dcm/package.json @@ -27,7 +27,8 @@ "prettier:check": "prettier --check .", "prettier:fix": "prettier --write .", "new": "backstage-cli new --scope @red-hat-developer-hub", - "postinstall": "cd ../../ && yarn install" + "postinstall": "cd ../../ && yarn install", + "e2e-test": "playwright test" }, "workspaces": { "packages": [ @@ -44,6 +45,7 @@ "devDependencies": { "@backstage/cli": "^0.35.2", "@backstage/e2e-test-utils": "^0.1.1", + "@playwright/test": "1.58.2", "@backstage/repo-tools": "^0.16.2", "@changesets/cli": "^2.27.1", "@types/jest": "^29.5.12", diff --git a/workspaces/dcm/packages/app/e2e-tests/app.test.ts b/workspaces/dcm/packages/app/e2e-tests/app.test.ts new file mode 100644 index 0000000000..97708a5d16 --- /dev/null +++ b/workspaces/dcm/packages/app/e2e-tests/app.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from '@playwright/test'; +import { performGuestLogin } from './fixtures/auth'; + +const devMode = !process.env.PLAYWRIGHT_URL; + +test('App should render the welcome page', async ({ page }) => { + await performGuestLogin(page); + + if (devMode) { + await expect( + page.getByRole('heading', { name: 'Red Hat Catalog' }), + ).toBeVisible({ timeout: 10000 }); + } else { + await expect( + page.getByRole('heading', { name: 'Welcome back!' }), + ).toBeVisible({ timeout: 10000 }); + } +}); diff --git a/workspaces/dcm/packages/app/e2e-tests/dcm-catalog-items.test.ts b/workspaces/dcm/packages/app/e2e-tests/dcm-catalog-items.test.ts new file mode 100644 index 0000000000..32591aca82 --- /dev/null +++ b/workspaces/dcm/packages/app/e2e-tests/dcm-catalog-items.test.ts @@ -0,0 +1,259 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from '@playwright/test'; +import { DcmPage } from './pages/DcmPage'; +import * as path from 'node:path'; + +const suffix = () => Date.now().toString(36).slice(-5); + +test.describe('DCM Catalog Items & Instances @dcm', () => { + let dcm: DcmPage; + + test.beforeEach(async ({ page }) => { + dcm = new DcmPage(page); + await dcm.loginAsGuest(); + await dcm.navigateToDataCenter(); + }); + + // ── Catalog Items ───────────────────────────────────────────────────── + + test('FLPATH-4200: Catalog items tab shows Pet Clinic with correct columns', async () => { + await dcm.clickTab('Catalog items'); + await dcm.verifyTableVisible(); + + await dcm.verifyColumnHeader('Display name'); + await dcm.verifyColumnHeader('API version'); + await dcm.verifyColumnHeader('Service type'); + await dcm.verifyColumnHeader('Fields'); + await dcm.verifyColumnHeader('Created'); + + await dcm.verifyCellContent('Pet Clinic'); + await dcm.verifyCellContent('three-tier-app-demo'); + }); + + test('FLPATH-4200: Pet Clinic has Edit and Delete actions', async () => { + await dcm.clickTab('Catalog items'); + await dcm.verifyTableVisible(); + + const row = dcm.page.locator('table tbody tr', { hasText: 'Pet Clinic' }); + await expect(row.getByRole('button', { name: 'Edit' })).toBeVisible(); + await expect(row.getByRole('button', { name: 'Delete' })).toBeVisible(); + }); + + test('FLPATH-4200: Create and delete a catalog item via drawer', async ({ + page, + }) => { + const name = `E2E Item ${suffix()}`; + await dcm.clickTab('Catalog items'); + await dcm.clickCreateCatalogItem(); + + await dcm.fillCatalogItemForm({ + displayName: name, + apiVersion: 'v1alpha1', + serviceType: 'container', + }); + + const pathField = page.locator('label:has-text("Path *") + div input'); + if ((await pathField.count()) > 0) { + await pathField.first().click(); + await pathField.first().fill('config.replicas'); + } else { + const altPath = page.getByLabel('Path *'); + await altPath.click(); + await altPath.fill('config.replicas'); + } + + await dcm.submitDialog('Create'); + await dcm.waitForTableRefresh(); + + await dcm.verifyCellContent(name); + + await dcm.clickDeleteOnRow(name); + await dcm.confirmDelete(); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + await dcm.verifyNoCellContent(name); + }); + + test('FLPATH-4200: Edit a catalog item', async () => { + await dcm.clickTab('Catalog items'); + + await dcm.clickEditOnRow('Pet Clinic'); + await expect( + dcm.page.getByRole('heading', { name: 'Edit catalog item' }), + ).toBeVisible({ timeout: 5000 }); + + await dcm.cancelDialog(); + }); + + // ── Instances ───────────────────────────────────────────────────────── + + test('FLPATH-4200: Instances tab shows correct columns or empty state', async ({ + page, + }) => { + await dcm.clickTab('Instances'); + const hasTable = await page + .locator('table') + .first() + .isVisible() + .catch(() => false); + + if (hasTable) { + await dcm.verifyColumnHeader('Display name'); + await dcm.verifyColumnHeader('Catalog item'); + await dcm.verifyColumnHeader('Resource ID'); + await dcm.verifyColumnHeader('API version'); + await dcm.verifyColumnHeader('Created'); + } + + await expect( + page.getByRole('button', { name: 'Create', exact: true }), + ).toBeVisible(); + }); + + test('FLPATH-4200: Create instance dialog opens and form is fillable', async ({ + page, + }) => { + await dcm.clickTab('Instances'); + await dcm.clickCreateInstance(); + + await dcm.fillInstanceForm({ + displayName: `E2E Instance ${suffix()}`, + catalogItem: 'Pet Clinic', + apiVersion: 'v1alpha1', + }); + + await expect(page.getByText('Field values')).toBeVisible({ + timeout: 5000, + }); + + await dcm.submitDialog('Create'); + await page.waitForTimeout(2000); + + const dialogVisible = await page + .locator('[role="dialog"]') + .isVisible() + .catch(() => false); + + if (dialogVisible) { + await expect(page.locator('[role="alert"]').first()).toBeVisible({ + timeout: 5000, + }); + await dcm.cancelDialog(); + } else { + await dcm.waitForTableRefresh(); + const hasInstance = await page + .getByRole('cell', { name: /E2E Instance/ }) + .first() + .isVisible() + .catch(() => false); + if (hasInstance) { + const row = page.locator('table tbody tr', { + hasText: /E2E Instance/, + }); + await row.getByRole('button', { name: 'Delete instance' }).click(); + await dcm.confirmDelete(); + await dcm.waitForDialogClosed(); + } + } + }); + + // ── File Import ────────────────────────────────────────────────────── + + test('FLPATH-4274: Valid YAML file import populates catalog item form', async ({ + page, + }) => { + const fixturePath = path.resolve( + __dirname, + 'fixtures/dcm/valid-catalog-item.yaml', + ); + await dcm.clickTab('Catalog items'); + await dcm.clickCreateCatalogItem(); + + await dcm.importCatalogItemFile(fixturePath); + + const displayName = page.locator( + 'label:has-text("Display name *") + div input', + ); + await expect(displayName.first()).toHaveValue('E2E Import Test Item'); + + const apiVersion = page.locator( + 'label:has-text("API version *") + div input', + ); + await expect(apiVersion.first()).toHaveValue('v1alpha1'); + + const pathFields = page.locator('label:has-text("Path *") + div input'); + await expect(pathFields).toHaveCount(2, { timeout: 5000 }); + await expect(pathFields.nth(0)).toHaveValue('config.replicas'); + await expect(pathFields.nth(1)).toHaveValue('config.region'); + + await dcm.submitDialog('Create'); + await dcm.waitForTableRefresh(); + await dcm.verifyCellContent('E2E Import Test Item'); + + await dcm.clickDeleteOnRow('E2E Import Test Item'); + await dcm.confirmDelete(); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + }); + + test('FLPATH-4274: Invalid YAML file import should show error feedback', async ({ + page, + }) => { + const fixturePath = path.resolve( + __dirname, + 'fixtures/dcm/invalid-catalog-item.yaml', + ); + await dcm.clickTab('Catalog items'); + await dcm.clickCreateCatalogItem(); + + const displayNameBefore = await page + .locator('label:has-text("Display name *") + div input') + .first() + .inputValue(); + + await dcm.importCatalogItemFile(fixturePath); + + const displayNameAfter = await page + .locator('label:has-text("Display name *") + div input') + .first() + .inputValue(); + expect(displayNameAfter).toBe(displayNameBefore); + + // FLPATH-4274: Error feedback MUST be shown for invalid files. + // This will fail until the fix ships, then pass automatically. + const errorFeedback = page + .locator('[class*="MuiAlert"]') + .first() + .or(page.locator('[class*="MuiSnackbar"]').first()); + await expect(errorFeedback).toBeVisible({ timeout: 5000 }); + + await dcm.cancelDialog(); + }); + + // ── Resources ───────────────────────────────────────────────────────── + + test('FLPATH-4200: Resources tab shows content or empty state', async ({ + page, + }) => { + await dcm.clickTab('Resources'); + + await expect( + page.locator('table').first().or(page.getByText('No resources found')), + ).toBeVisible({ timeout: 15000 }); + }); +}); diff --git a/workspaces/dcm/packages/app/e2e-tests/dcm-policies.test.ts b/workspaces/dcm/packages/app/e2e-tests/dcm-policies.test.ts new file mode 100644 index 0000000000..820296c5ba --- /dev/null +++ b/workspaces/dcm/packages/app/e2e-tests/dcm-policies.test.ts @@ -0,0 +1,163 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from '@playwright/test'; +import { DcmPage } from './pages/DcmPage'; + +const suffix = () => Date.now().toString(36).slice(-5); +const uniquePriority = () => String(Math.floor(Math.random() * 900) + 50); + +test.describe('DCM Policies CRUD @dcm', () => { + let dcm: DcmPage; + + test.beforeEach(async ({ page }) => { + dcm = new DcmPage(page); + await dcm.loginAsGuest(); + await dcm.navigateToDataCenter(); + await dcm.clickTab('Policies'); + }); + + test('FLPATH-4200: Create a new GLOBAL policy', async () => { + const name = `E2E Global ${suffix()}`; + const priority = uniquePriority(); + await dcm.clickCreatePolicy(); + await dcm.fillPolicyForm({ + displayName: name, + description: 'Automated test policy — safe to delete', + policyType: 'GLOBAL', + priority, + regoCode: + 'package dcm.placement\n\nselected_provider := "k8s-container-provider"', + }); + await dcm.submitDialog('Create'); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + + await dcm.verifyCellContent(name); + await dcm.verifyCellContent('GLOBAL'); + + await dcm.clickDeleteOnRow(name); + await dcm.confirmDelete(); + await dcm.waitForDialogClosed(); + }); + + test('FLPATH-4200: Create a USER policy', async () => { + const name = `E2E User ${suffix()}`; + const priority = uniquePriority(); + await dcm.clickCreatePolicy(); + await dcm.fillPolicyForm({ + displayName: name, + policyType: 'USER', + priority, + regoCode: + 'package dcm.placement\n\nselected_provider := "k8s-container-provider"', + }); + await dcm.submitDialog('Create'); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + + await dcm.verifyCellContent(name); + await dcm.verifyCellContent('USER'); + + await dcm.clickDeleteOnRow(name); + await dcm.confirmDelete(); + await dcm.waitForDialogClosed(); + }); + + test('FLPATH-4200: Policies table shows correct columns', async ({ + page, + }) => { + const hasTable = await page + .locator('table') + .first() + .isVisible() + .catch(() => false); + + if (hasTable) { + await dcm.verifyColumnHeader('Display name'); + await dcm.verifyColumnHeader('Type'); + await dcm.verifyColumnHeader('Priority'); + await dcm.verifyColumnHeader('Enabled'); + await dcm.verifyColumnHeader('Description'); + } + + await expect( + page.getByRole('button', { name: 'Create', exact: true }), + ).toBeVisible(); + }); + + test('FLPATH-4200: Toggle policy enabled/disabled', async () => { + const name = `E2E Toggle ${suffix()}`; + const priority = uniquePriority(); + await dcm.clickCreatePolicy(); + await dcm.fillPolicyForm({ + displayName: name, + policyType: 'GLOBAL', + priority, + regoCode: + 'package dcm.placement\n\nselected_provider := "k8s-container-provider"', + }); + await dcm.submitDialog('Create'); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + + await dcm.togglePolicyEnabled(name); + await dcm.waitForTableRefresh(); + + await dcm.togglePolicyEnabled(name); + await dcm.waitForTableRefresh(); + + await dcm.clickDeleteOnRow(name); + await dcm.confirmDelete(); + await dcm.waitForDialogClosed(); + }); + + test('FLPATH-4200: Edit a policy description', async () => { + const name = `E2E Edit ${suffix()}`; + const priority = uniquePriority(); + await dcm.clickCreatePolicy(); + await dcm.fillPolicyForm({ + displayName: name, + description: 'Original description', + policyType: 'GLOBAL', + priority, + regoCode: + 'package dcm.placement\n\nselected_provider := "k8s-container-provider"', + }); + await dcm.submitDialog('Create'); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + + await dcm.clickEditOnRow(name); + await expect( + dcm.page.getByRole('heading', { name: 'Edit policy' }), + ).toBeVisible({ timeout: 5000 }); + + const descField = dcm.page.locator( + 'label:has-text("Description") + div textarea', + ); + await descField.first().click(); + await descField.first().fill('Updated by E2E test'); + + await dcm.submitDialog('Save'); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + + await dcm.clickDeleteOnRow(name); + await dcm.confirmDelete(); + await dcm.waitForDialogClosed(); + }); +}); diff --git a/workspaces/dcm/packages/app/e2e-tests/dcm-providers.test.ts b/workspaces/dcm/packages/app/e2e-tests/dcm-providers.test.ts new file mode 100644 index 0000000000..8e5d7f667c --- /dev/null +++ b/workspaces/dcm/packages/app/e2e-tests/dcm-providers.test.ts @@ -0,0 +1,152 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from '@playwright/test'; +import { DcmPage } from './pages/DcmPage'; + +const suffix = () => Date.now().toString(36).slice(-5); + +test.describe('DCM Providers CRUD @dcm', () => { + let dcm: DcmPage; + + test.beforeEach(async ({ page }) => { + dcm = new DcmPage(page); + await dcm.loginAsGuest(); + await dcm.navigateToDataCenter(); + }); + + test('FLPATH-4200: Register a new provider with service type and operations', async () => { + const name = `e2e-provider-${suffix()}`; + await dcm.clickRegisterProvider(); + await dcm.fillProviderForm({ + name, + endpoint: 'https://e2e-test.example.com', + serviceType: 'container', + schemaVersion: 'v1alpha1', + operations: ['create', 'read', 'delete'], + }); + await dcm.submitDialog('Register'); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + + await dcm.verifyCellContent(name); + + const displayName = name + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); + await dcm.clickDeleteOnRow(displayName); + await dcm.confirmDelete(); + await dcm.waitForDialogClosed(); + }); + + test('FLPATH-4200: Register provider dialog shows all service type options', async ({ + page, + }) => { + await dcm.clickRegisterProvider(); + + const stSelect = page + .locator('label:has-text("Service type *")') + .locator('..') + .locator('[role="button"], select'); + await stSelect.first().click(); + + for (const st of [ + 'cluster', + 'container', + 'database', + 'three-tier-app-demo', + 'vm', + ]) { + await expect(page.getByRole('option', { name: st })).toBeVisible(); + } + + await page.keyboard.press('Escape'); + await dcm.cancelDialog(); + }); + + test('FLPATH-4200: Register provider dialog shows all operation options', async ({ + page, + }) => { + await dcm.clickRegisterProvider(); + + const opsSelect = page + .locator('label:has-text("Operations")') + .locator('..') + .locator('[role="button"], select'); + await opsSelect.first().click(); + + for (const op of ['create', 'read', 'update', 'delete', 'list', 'patch']) { + await expect(page.getByRole('option', { name: op })).toBeVisible(); + } + + await page.keyboard.press('Escape'); + await dcm.cancelDialog(); + }); + + test('FLPATH-4200: Edit an existing provider', async () => { + await dcm.clickEditOnRow('K8s Container Provider'); + await expect( + dcm.page.getByRole('heading', { name: 'Edit provider' }), + ).toBeVisible({ timeout: 5000 }); + + await dcm.cancelDialog(); + await dcm.waitForDialogClosed(); + }); + + test('FLPATH-4200: Search filters providers table', async ({ page }) => { + await dcm.searchFor('K8s'); + await dcm.verifyCellContent('K8s Container Provider'); + + await dcm.searchFor('nonexistent-provider-xyz'); + await expect( + page + .getByText(/no.*found/i) + .or(page.locator('table').first().locator('tbody tr').first()), + ).toBeVisible({ timeout: 10000 }); + + await dcm.clearSearch(); + await dcm.verifyTableHasRows(1); + }); + + test('FLPATH-4200: Register and delete a provider', async () => { + const name = `e2e-del-${suffix()}`; + await dcm.clickRegisterProvider(); + await dcm.fillProviderForm({ + name, + endpoint: 'https://delete-me.example.com', + serviceType: 'vm', + schemaVersion: 'v1alpha1', + }); + await dcm.submitDialog('Register'); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + + const countBefore = await dcm.getTableRowCount(); + const displayName = name + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); + + await dcm.clickDeleteOnRow(displayName); + await dcm.confirmDelete(); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + + const countAfter = await dcm.getTableRowCount(); + expect(countAfter).toBeLessThan(countBefore); + }); +}); diff --git a/workspaces/dcm/packages/app/e2e-tests/dcm-regressions.test.ts b/workspaces/dcm/packages/app/e2e-tests/dcm-regressions.test.ts new file mode 100644 index 0000000000..3f17c2d8b2 --- /dev/null +++ b/workspaces/dcm/packages/app/e2e-tests/dcm-regressions.test.ts @@ -0,0 +1,488 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from '@playwright/test'; +import { DcmPage } from './pages/DcmPage'; + +const suffix = () => Date.now().toString(36).slice(-5); +const uniquePriority = () => String(Math.floor(Math.random() * 900) + 50); + +test.describe('DCM Bug Regression Tests @dcm', () => { + let dcm: DcmPage; + + test.beforeEach(async ({ page }) => { + dcm = new DcmPage(page); + await dcm.loginAsGuest(); + await dcm.navigateToDataCenter(); + }); + + test('FLPATH-4246: /dcm/providers deep link selects Providers tab', async ({ + page, + }) => { + await page.goto('/dcm/providers', { timeout: 60000 }); + await page.waitForLoadState('networkidle'); + await dcm.verifyPageTitle(); + await dcm.verifyTabSelected('Providers'); + await dcm.verifyTableVisible(); + await dcm.verifyCellContent('k8s-container-provider'); + }); + + test('FLPATH-4241: Delete button is disabled while delete is in progress', async ({ + page, + }) => { + const id = suffix(); + const name = `e2e-delguard-${id}`; + await dcm.clickRegisterProvider(); + await dcm.fillProviderForm({ + name, + endpoint: 'https://del-guard-test.example.com', + serviceType: 'container', + schemaVersion: 'v1alpha1', + }); + await dcm.submitDialog('Register'); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + + const displayName = name + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); + await dcm.verifyCellContent(displayName); + + await dcm.clickDeleteOnRow(displayName); + + const deleteBtn = page + .locator('[role="dialog"]') + .getByRole('button', { name: 'Delete' }); + await deleteBtn.click(); + + const isDisabled = await deleteBtn.isDisabled().catch(() => true); + + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + + await dcm.verifyNoCellContent(displayName); + + expect(isDisabled).toBe(true); + }); + + test('FLPATH-4242: Search resets pagination to page 1', async ({ page }) => { + const names: string[] = []; + for (let i = 0; i < 6; i++) { + const name = `e2e-page-${suffix()}-${i}`; + names.push(name); + await dcm.clickRegisterProvider(); + await dcm.fillProviderForm({ + name, + endpoint: `https://page-test-${i}.example.com`, + serviceType: 'container', + schemaVersion: 'v1alpha1', + }); + await dcm.submitDialog('Register'); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + } + + const nextPageBtn = page.getByRole('button', { name: 'Next Page' }); + await expect(nextPageBtn).toBeEnabled({ timeout: 5000 }); + await nextPageBtn.click(); + await dcm.waitForTableRefresh(); + + await dcm.searchFor('K8s Container Provider'); + await dcm.waitForTableRefresh(); + + await dcm.verifyCellContent('K8s Container Provider'); + + await dcm.clearSearch(); + await dcm.waitForTableRefresh(); + + for (const name of names) { + const displayName = name + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); + try { + await dcm.clickDeleteOnRow(displayName); + await dcm.confirmDelete(); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + } catch { + const nextBtn = page.getByRole('button', { name: 'Next Page' }); + if (await nextBtn.isEnabled().catch(() => false)) { + await nextBtn.click(); + await dcm.waitForTableRefresh(); + try { + await dcm.clickDeleteOnRow(displayName); + await dcm.confirmDelete(); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + } catch { + // skip — already cleaned or not visible + } + } + } + } + }); + + test('FLPATH-4243: Policy toggle persists state after toggling', async ({ + page, + }) => { + await dcm.clickTab('Policies'); + + const name = `E2E Toggle Persist ${suffix()}`; + const priority = uniquePriority(); + await dcm.clickCreatePolicy(); + await dcm.fillPolicyForm({ + displayName: name, + policyType: 'GLOBAL', + priority, + regoCode: + 'package dcm.placement\n\nselected_provider := "k8s-container-provider"', + }); + await dcm.submitDialog('Create'); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + + const row = page.locator('table tbody tr', { hasText: name }); + + const switchInput = row.locator('[class*="MuiSwitch"] input'); + const initialChecked = await switchInput.isChecked(); + + await dcm.togglePolicyEnabled(name); + await dcm.waitForTableRefresh(); + + const afterToggle = await switchInput.isChecked(); + expect(afterToggle).toBe(!initialChecked); + + await dcm.navigateToDataCenter(); + await dcm.clickTab('Policies'); + await dcm.waitForTableRefresh(); + + const reloadedRow = page.locator('table tbody tr', { hasText: name }); + const reloadedSwitch = reloadedRow.locator('[class*="MuiSwitch"] input'); + const persistedState = await reloadedSwitch.isChecked(); + expect(persistedState).toBe(!initialChecked); + + await dcm.clickDeleteOnRow(name); + await dcm.confirmDelete(); + await dcm.waitForDialogClosed(); + }); + + test('FLPATH-4244: Policy Enabled chip and Switch agree for all states', async ({ + page, + }) => { + await dcm.clickTab('Policies'); + + const name = `E2E Chip State ${suffix()}`; + const priority = uniquePriority(); + await dcm.clickCreatePolicy(); + await dcm.fillPolicyForm({ + displayName: name, + policyType: 'GLOBAL', + priority, + regoCode: + 'package dcm.placement\n\nselected_provider := "k8s-container-provider"', + }); + await dcm.submitDialog('Create'); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + + const row = page.locator('table tbody tr', { hasText: name }); + + const enabledChip = row + .locator('[class*="MuiChip"]') + .filter({ hasText: /^Yes$|^No$/ }); + const chipText = await enabledChip.first().textContent(); + const switchInput = row.locator('[class*="MuiSwitch"] input'); + const isChecked = await switchInput.isChecked(); + + if (chipText === 'Yes') { + expect(isChecked).toBe(true); + } else if (chipText === 'No') { + expect(isChecked).toBe(false); + } + + await dcm.togglePolicyEnabled(name); + await dcm.waitForTableRefresh(); + + const chipTextAfter = await enabledChip.first().textContent(); + const isCheckedAfter = await switchInput.isChecked(); + + if (chipTextAfter === 'Yes') { + expect(isCheckedAfter).toBe(true); + } else if (chipTextAfter === 'No') { + expect(isCheckedAfter).toBe(false); + } + + await dcm.clickDeleteOnRow(name); + await dcm.confirmDelete(); + await dcm.waitForDialogClosed(); + }); + + test('FLPATH-4245: Service Types tab loads all five types from backend', async () => { + await dcm.clickTab('Service types'); + await dcm.verifyTableVisible(); + await dcm.verifyTableHasRows(5); + + for (const st of [ + 'cluster', + 'container', + 'database', + 'three-tier-app-demo', + 'vm', + ]) { + await dcm.verifyCellContent(st); + } + }); + + test('FLPATH-4249: Provider name is read-only in edit mode', async ({ + page, + }) => { + await dcm.clickEditOnRow('K8s Container Provider'); + await expect( + page.getByRole('heading', { name: 'Edit provider' }), + ).toBeVisible({ timeout: 5000 }); + + const nameInput = page + .locator('label:has-text("Name *") + div input') + .first() + .or(page.getByLabel('Name *')); + + const isDisabled = await nameInput + .first() + .isDisabled() + .catch(() => false); + const isReadonly = await nameInput + .first() + .getAttribute('readonly') + .then(v => v !== null) + .catch(() => false); + + expect(isDisabled || isReadonly).toBe(true); + + await dcm.cancelDialog(); + }); + + test('FLPATH-4250: Whitespace-only values rejected in policy form', async ({ + page, + }) => { + await dcm.clickTab('Policies'); + await dcm.clickCreatePolicy(); + + await dcm.fillPolicyForm({ + displayName: ' ', + policyType: 'GLOBAL', + regoCode: ' ', + }); + + const createBtn = page + .locator('[role="dialog"]') + .getByRole('button', { name: 'Create' }); + await expect(createBtn).toBeDisabled(); + + await dcm.cancelDialog(); + }); +}); + +test.describe('DCM UX Regression Tests @dcm', () => { + let dcm: DcmPage; + + test.beforeEach(async ({ page }) => { + dcm = new DcmPage(page); + await dcm.loginAsGuest(); + await dcm.navigateToDataCenter(); + }); + + test('FLPATH-4253: Success snackbar appears after provider registration', async ({ + page, + }) => { + const name = `e2e-toast-${suffix()}`; + await dcm.clickRegisterProvider(); + await dcm.fillProviderForm({ + name, + endpoint: 'https://toast-test.example.com', + serviceType: 'container', + schemaVersion: 'v1alpha1', + }); + await dcm.submitDialog('Register'); + await dcm.waitForDialogClosed(); + + await dcm.verifySuccessSnackbar(); + + await dcm.waitForTableRefresh(); + const displayName = name + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); + await dcm.clickDeleteOnRow(displayName); + await dcm.confirmDelete(); + await dcm.waitForDialogClosed(); + }); + + test('FLPATH-4253: Success snackbar appears after policy creation', async () => { + await dcm.clickTab('Policies'); + + const name = `E2E Toast Policy ${suffix()}`; + const priority = uniquePriority(); + await dcm.clickCreatePolicy(); + await dcm.fillPolicyForm({ + displayName: name, + policyType: 'GLOBAL', + priority, + regoCode: + 'package dcm.placement\n\nselected_provider := "k8s-container-provider"', + }); + await dcm.submitDialog('Create'); + await dcm.waitForDialogClosed(); + + await dcm.verifySuccessSnackbar(); + + await dcm.waitForTableRefresh(); + await dcm.clickDeleteOnRow(name); + await dcm.confirmDelete(); + await dcm.waitForDialogClosed(); + }); + + test('FLPATH-4265: Instance create button is disabled when form is empty', async ({ + page, + }) => { + await dcm.clickTab('Instances'); + await dcm.clickCreateInstance(); + + const createBtn = page + .locator('[role="dialog"]') + .getByRole('button', { name: 'Create' }); + await expect(createBtn).toBeDisabled(); + + await dcm.cancelDialog(); + }); + + test('FLPATH-4256: Validation errors shown on submit attempt with empty fields', async ({ + page, + }) => { + await dcm.clickRegisterProvider(); + + await expect( + page.locator('label:has-text("Name *")').first(), + ).toBeVisible(); + await expect( + page.locator('label:has-text("Endpoint *")').first(), + ).toBeVisible(); + await expect( + page.locator('label:has-text("Service type *")').first(), + ).toBeVisible(); + + const nameInput = page.locator('label:has-text("Name *") + div input'); + await nameInput.first().click(); + await nameInput.first().blur(); + + const nameError = page.locator('p[class*="MuiFormHelperText"]').first(); + await expect(nameError).toBeVisible({ timeout: 3000 }); + + await dcm.cancelDialog(); + }); + + test('FLPATH-4111: Provider table does not render empty padding rows', async ({ + page, + }) => { + const names: string[] = []; + for (let i = 0; i < 3; i++) { + const name = `e2e-emptyrows-${suffix()}-${i}`; + names.push(name); + await dcm.clickRegisterProvider(); + await dcm.fillProviderForm({ + name, + endpoint: `https://emptyrows-${i}.example.com`, + serviceType: 'container', + schemaVersion: 'v1alpha1', + }); + await dcm.submitDialog('Register'); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + } + + const emptyRows = await dcm.getEmptyRowCount(); + expect(emptyRows).toBe(0); + + const totalRows = await dcm.getTableRowCount(); + const rppText = await dcm.getRowsPerPageValue(); + const pageSize = parseInt(rppText, 10); + if (!isNaN(pageSize)) { + expect(totalRows).toBeLessThanOrEqual(pageSize); + } + + for (const name of names) { + const displayName = name + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); + try { + await dcm.clickDeleteOnRow(displayName); + await dcm.confirmDelete(); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + } catch { + /* already cleaned */ + } + } + }); + + test('FLPATH-4112: Rows-per-page selection persists after browser refresh', async ({ + page, + }) => { + const names: string[] = []; + for (let i = 0; i < 6; i++) { + const name = `e2e-rpp-${suffix()}-${i}`; + names.push(name); + await dcm.clickRegisterProvider(); + await dcm.fillProviderForm({ + name, + endpoint: `https://rpp-${i}.example.com`, + serviceType: 'container', + schemaVersion: 'v1alpha1', + }); + await dcm.submitDialog('Register'); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + } + + await dcm.setRowsPerPage('10'); + const before = await dcm.getRowsPerPageValue(); + expect(before).toContain('10'); + + await page.reload(); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); + + const after = await dcm.getRowsPerPageValue(); + expect(after).toContain('10'); + + for (const name of names) { + const displayName = name + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); + try { + await dcm.clickDeleteOnRow(displayName); + await dcm.confirmDelete(); + await dcm.waitForDialogClosed(); + await dcm.waitForTableRefresh(); + } catch { + /* already cleaned */ + } + } + }); +}); diff --git a/workspaces/dcm/packages/app/e2e-tests/dcm-smoke.test.ts b/workspaces/dcm/packages/app/e2e-tests/dcm-smoke.test.ts new file mode 100644 index 0000000000..52caa69b74 --- /dev/null +++ b/workspaces/dcm/packages/app/e2e-tests/dcm-smoke.test.ts @@ -0,0 +1,158 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from '@playwright/test'; +import { DcmPage, SERVICE_TYPES } from './pages/DcmPage'; + +test.describe('DCM Plugin Smoke Tests @dcm', () => { + let dcm: DcmPage; + + test.beforeEach(async ({ page }) => { + dcm = new DcmPage(page); + await dcm.loginAsGuest(); + }); + + test('FLPATH-4200: Data Center page renders from sidebar navigation', async () => { + await dcm.clickDataCenterNavBarItem(); + await dcm.verifyPageTitle(); + await dcm.verifyTabSelected('Providers'); + }); + + test('FLPATH-4200: Data Center page renders via direct URL', async () => { + await dcm.navigateToDataCenter(); + await dcm.verifyPageTitle(); + }); + + test('FLPATH-4200: All six tabs are visible and clickable', async () => { + await dcm.navigateToDataCenter(); + await dcm.verifyAllTabsVisible(); + + await dcm.clickTab('Policies'); + await dcm.verifyTabSelected('Policies'); + + await dcm.clickTab('Service types'); + await dcm.verifyTabSelected('Service types'); + + await dcm.clickTab('Catalog items'); + await dcm.verifyTabSelected('Catalog items'); + + await dcm.clickTab('Instances'); + await dcm.verifyTabSelected('Instances'); + + await dcm.clickTab('Resources'); + await dcm.verifyTabSelected('Resources'); + + await dcm.clickTab('Providers'); + await dcm.verifyTabSelected('Providers'); + }); + + test('FLPATH-4200: Providers tab shows existing K8s Container Provider', async () => { + await dcm.navigateToDataCenter(); + await dcm.verifyTableVisible(); + await dcm.verifyTableHasRows(1); + + await dcm.verifyColumnHeader('Display name'); + await dcm.verifyColumnHeader('Name'); + await dcm.verifyColumnHeader('Endpoint'); + await dcm.verifyColumnHeader('Service type'); + await dcm.verifyColumnHeader('Operations'); + await dcm.verifyColumnHeader('Status'); + + await dcm.verifyCellContent('K8s Container Provider'); + await dcm.verifyCellContent('k8s-container-provider'); + }); + + test('FLPATH-4200: Service types tab shows all five default types', async () => { + await dcm.navigateToDataCenter(); + await dcm.clickTab('Service types'); + await dcm.verifyTableVisible(); + await dcm.verifyTableHasRows(5); + + await dcm.verifyColumnHeader('Service type'); + await dcm.verifyColumnHeader('API version'); + await dcm.verifyColumnHeader('Path'); + await dcm.verifyColumnHeader('Created'); + + for (const st of SERVICE_TYPES) { + await dcm.verifyCellContent(st); + } + }); + + test('FLPATH-4200: Catalog items tab shows Pet Clinic item', async () => { + await dcm.navigateToDataCenter(); + await dcm.clickTab('Catalog items'); + await dcm.verifyTableVisible(); + await dcm.verifyTableHasRows(1); + + await dcm.verifyColumnHeader('Display name'); + await dcm.verifyColumnHeader('API version'); + await dcm.verifyColumnHeader('Service type'); + await dcm.verifyColumnHeader('Fields'); + await dcm.verifyColumnHeader('Created'); + + await dcm.verifyCellContent('Pet Clinic'); + }); + + test('FLPATH-4200: Policies tab has Create button', async ({ page }) => { + await dcm.navigateToDataCenter(); + await dcm.clickTab('Policies'); + await expect( + page.locator('table').first().or(page.getByText('No policies defined')), + ).toBeVisible({ timeout: 15000 }); + await expect(page.getByRole('button', { name: 'Create' })).toBeVisible(); + }); + + test('FLPATH-4200: Instances tab shows empty state', async ({ page }) => { + await dcm.navigateToDataCenter(); + await dcm.clickTab('Instances'); + await expect( + page + .locator('table') + .first() + .or(page.getByText('No instances provisioned')), + ).toBeVisible({ timeout: 15000 }); + }); + + test('FLPATH-4200: Resources tab renders without error', async ({ page }) => { + await dcm.navigateToDataCenter(); + await dcm.clickTab('Resources'); + await expect( + page.locator('table').first().or(page.getByText('No resources found')), + ).toBeVisible({ timeout: 15000 }); + }); + + test('FLPATH-3247: /dcm/service-specs route loads without error', async ({ + page, + }) => { + await page.goto('/dcm/service-specs', { timeout: 60000 }); + await page.waitForLoadState('networkidle'); + await dcm.verifyPageTitle(); + const errorAlert = page.locator('[class*="MuiAlert-standardError"]'); + await expect(errorAlert).toHaveCount(0); + }); + + test('FLPATH-4032: DCM plugin loads data through API proxy', async () => { + await dcm.navigateToDataCenter(); + await dcm.verifyTableVisible(); + await dcm.verifyCellContent('k8s-container-provider'); + + await dcm.clickTab('Service types'); + await dcm.verifyTableHasRows(5); + + await dcm.clickTab('Catalog items'); + await dcm.verifyCellContent('Pet Clinic'); + }); +}); diff --git a/workspaces/dcm/packages/app/e2e-tests/fixtures/auth.ts b/workspaces/dcm/packages/app/e2e-tests/fixtures/auth.ts new file mode 100644 index 0000000000..b275139af7 --- /dev/null +++ b/workspaces/dcm/packages/app/e2e-tests/fixtures/auth.ts @@ -0,0 +1,31 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Page } from '@playwright/test'; + +export async function performGuestLogin(page: Page) { + await page.goto('/'); + await page.waitForLoadState('domcontentloaded', { timeout: 30000 }); + + const enterButton = page.locator('button:has-text("Enter")'); + await enterButton.waitFor({ state: 'visible', timeout: 15000 }); + await enterButton.click(); + + await page + .locator('nav') + .first() + .waitFor({ state: 'visible', timeout: 60000 }); +} diff --git a/workspaces/dcm/packages/app/e2e-tests/fixtures/dcm/invalid-catalog-item.yaml b/workspaces/dcm/packages/app/e2e-tests/fixtures/dcm/invalid-catalog-item.yaml new file mode 100644 index 0000000000..7b1a8a6aad --- /dev/null +++ b/workspaces/dcm/packages/app/e2e-tests/fixtures/dcm/invalid-catalog-item.yaml @@ -0,0 +1,3 @@ +this is not valid yaml: [unterminated + - broken: {nope +display_name: "unclosed string diff --git a/workspaces/dcm/packages/app/e2e-tests/fixtures/dcm/valid-catalog-item.yaml b/workspaces/dcm/packages/app/e2e-tests/fixtures/dcm/valid-catalog-item.yaml new file mode 100644 index 0000000000..5aa6f3723b --- /dev/null +++ b/workspaces/dcm/packages/app/e2e-tests/fixtures/dcm/valid-catalog-item.yaml @@ -0,0 +1,22 @@ +display_name: E2E Import Test Item +api_version: v1alpha1 +spec: + service_type: container + fields: + - path: config.replicas + display_name: Replicas + editable: true + default: 3 + validation_schema: + type: integer + minimum: 1 + - path: config.region + display_name: Region + editable: true + default: us-east-1 + validation_schema: + type: string + enum: + - us-east-1 + - us-west-2 + - eu-west-1 diff --git a/workspaces/dcm/packages/app/e2e-tests/pages/DcmPage.ts b/workspaces/dcm/packages/app/e2e-tests/pages/DcmPage.ts new file mode 100644 index 0000000000..12992d5f72 --- /dev/null +++ b/workspaces/dcm/packages/app/e2e-tests/pages/DcmPage.ts @@ -0,0 +1,435 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, type Page } from '@playwright/test'; +import { performGuestLogin } from '../fixtures/auth'; + +const DCM_TABS = [ + 'Providers', + 'Policies', + 'Service types', + 'Catalog items', + 'Instances', + 'Resources', +] as const; + +type DcmTab = (typeof DCM_TABS)[number]; + +const SERVICE_TYPES = [ + 'cluster', + 'container', + 'database', + 'three-tier-app-demo', + 'vm', +] as const; + +type ServiceType = (typeof SERVICE_TYPES)[number]; + +export { DCM_TABS, SERVICE_TYPES }; +export type { DcmTab, ServiceType }; + +export class DcmPage { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async loginAsGuest() { + await performGuestLogin(this.page); + } + + // ── Navigation ──────────────────────────────────────────────────────── + + async navigateToDataCenter() { + await this.page.goto('/dcm', { timeout: 60000 }); + await this.page.waitForLoadState('networkidle'); + } + + async clickDataCenterNavBarItem() { + await this.page + .locator('[data-testid="sidebar-root"]') + .getByRole('link', { name: 'Data Center' }) + .click(); + await this.verifyPageTitle(); + } + + async verifyPageTitle() { + await expect( + this.page.locator('h3').filter({ hasText: 'Data Center' }), + ).toBeVisible({ timeout: 15000 }); + } + + // ── Tabs ────────────────────────────────────────────────────────────── + + async clickTab(tabName: DcmTab) { + await this.page.getByRole('tab', { name: tabName }).click(); + await this.page.waitForLoadState('networkidle'); + } + + async verifyTabVisible(tabName: DcmTab) { + await expect(this.page.getByRole('tab', { name: tabName })).toBeVisible({ + timeout: 10000, + }); + } + + async verifyAllTabsVisible() { + for (const tab of DCM_TABS) { + await this.verifyTabVisible(tab); + } + } + + async verifyTabSelected(tabName: DcmTab) { + await expect(this.page.getByRole('tab', { name: tabName })).toHaveAttribute( + 'aria-selected', + 'true', + ); + } + + // ── Table assertions ────────────────────────────────────────────────── + + async verifyTableVisible() { + await expect(this.page.locator('table').first()).toBeVisible({ + timeout: 15000, + }); + } + + async verifyTableHasRows(minRows = 1) { + const rows = this.page.locator('table').first().locator('tbody tr'); + await expect(rows.first()).toBeVisible({ timeout: 15000 }); + const count = await rows.count(); + expect(count).toBeGreaterThanOrEqual(minRows); + } + + async getTableRowCount(): Promise { + await this.verifyTableVisible(); + return this.page.locator('table').first().locator('tbody tr').count(); + } + + async verifyColumnHeader(headerText: string) { + await expect( + this.page.getByRole('columnheader', { name: headerText, exact: true }), + ).toBeVisible(); + } + + async verifyCellContent(text: string) { + await expect( + this.page.getByRole('cell', { name: text }).first(), + ).toBeVisible({ timeout: 10000 }); + } + + async verifyNoCellContent(text: string) { + await expect(this.page.getByRole('cell', { name: text })).toHaveCount(0, { + timeout: 10000, + }); + } + + // ── Search ──────────────────────────────────────────────────────────── + + async searchFor(text: string) { + const searchInput = this.page.getByRole('textbox', { name: 'Search' }); + await searchInput.click(); + await searchInput.fill(text); + } + + async clearSearch() { + await this.page.getByRole('button', { name: 'Clear search' }).click(); + } + + // ── Empty & error states ────────────────────────────────────────────── + + async verifyEmptyState(titleText: string) { + await expect(this.page.getByText(titleText)).toBeVisible({ + timeout: 10000, + }); + } + + async verifyLoadError() { + await expect(this.page.locator('[class*="MuiAlert"]').first()).toBeVisible({ + timeout: 10000, + }); + } + + async clickRetry() { + await this.page.getByRole('button', { name: 'Retry' }).click(); + } + + // ── Provider CRUD ───────────────────────────────────────────────────── + + async clickRegisterProvider() { + await this.page.getByRole('button', { name: 'Register' }).click(); + await expect( + this.page.getByRole('heading', { name: 'Register provider' }), + ).toBeVisible({ timeout: 5000 }); + } + + async fillProviderForm(opts: { + name: string; + endpoint: string; + serviceType: ServiceType; + schemaVersion: string; + operations?: string[]; + }) { + await this.fillTextField('Name *', opts.name); + await this.fillTextField('Endpoint *', opts.endpoint); + await this.selectOption('Service type *', opts.serviceType); + await this.fillTextField('Schema version *', opts.schemaVersion); + if (opts.operations && opts.operations.length > 0) { + await this.selectMultipleOptions('Operations', opts.operations); + } + } + + async clickEditOnRow(displayName: string) { + const row = this.page.locator('table tbody tr', { hasText: displayName }); + await row.getByRole('button', { name: 'Edit' }).click(); + } + + async clickDeleteOnRow(displayName: string) { + const row = this.page.locator('table tbody tr', { hasText: displayName }); + await row.getByRole('button', { name: 'Delete' }).click(); + } + + // ── Policy CRUD ─────────────────────────────────────────────────────── + + async clickCreateButton() { + await this.page + .getByRole('button', { name: 'Create', exact: true }) + .click(); + } + + async clickCreatePolicy() { + await this.clickCreateButton(); + await expect( + this.page.getByRole('heading', { name: 'Create policy' }), + ).toBeVisible({ timeout: 5000 }); + } + + async fillPolicyForm(opts: { + displayName: string; + description?: string; + policyType: 'GLOBAL' | 'USER'; + priority?: string; + regoCode: string; + enabled?: boolean; + }) { + await this.fillTextField('Display name *', opts.displayName); + if (opts.description) { + await this.fillTextField('Description', opts.description); + } + const policyTypeLabel = + opts.policyType === 'GLOBAL' + ? 'GLOBAL — applies to all requests' + : 'USER — applies per user'; + await this.selectOption('Policy type *', policyTypeLabel); + if (opts.priority) { + await this.fillTextField('Priority', opts.priority); + } + await this.fillTextField('Rego code *', opts.regoCode); + if (opts.enabled === false) { + const toggle = this.page.locator('input[type="checkbox"]'); + if (await toggle.isChecked()) { + await toggle.click({ force: true }); + } + } + } + + async togglePolicyEnabled(displayName: string) { + const row = this.page.locator('table tbody tr', { hasText: displayName }); + await row.locator('[class*="MuiSwitch"] input').click({ force: true }); + } + + // ── Catalog item CRUD (uses Drawer, not modal) ──────────────────────── + + async clickCreateCatalogItem() { + await this.clickCreateButton(); + await expect( + this.page.getByRole('heading', { name: 'Create catalog item' }), + ).toBeVisible({ timeout: 5000 }); + } + + async fillCatalogItemForm(opts: { + displayName: string; + apiVersion: string; + serviceType?: ServiceType; + }) { + await this.fillTextField('Display name *', opts.displayName); + await this.fillTextField('API version *', opts.apiVersion); + if (opts.serviceType) { + await this.selectOption('Service type', opts.serviceType); + } + } + + async addCatalogItemField(opts: { + path: string; + displayName?: string; + defaultValue?: string; + editable?: boolean; + }) { + await this.page.getByRole('button', { name: /add field/i }).click(); + const fieldSection = this.page.locator('[class*="fieldRow"]').last(); + await fieldSection.getByLabel('Path *').fill(opts.path); + if (opts.displayName) { + await fieldSection.getByLabel('Display name').fill(opts.displayName); + } + if (opts.defaultValue) { + await fieldSection.getByLabel('Default value').fill(opts.defaultValue); + } + } + + async closeCatalogItemDrawer() { + await this.page.getByRole('button', { name: 'Close' }).click(); + } + + async importCatalogItemFile(filePath: string) { + const fileInput = this.page.locator('input[type="file"][accept*=".yaml"]'); + await fileInput.setInputFiles(filePath); + await this.page.waitForTimeout(1500); + } + + // ── Instance CRUD ───────────────────────────────────────────────────── + + async clickCreateInstance() { + await this.clickCreateButton(); + await expect( + this.page.getByRole('heading', { + name: 'Create catalog item instance', + }), + ).toBeVisible({ timeout: 5000 }); + } + + async fillInstanceForm(opts: { + displayName: string; + catalogItem: string; + apiVersion: string; + }) { + await this.fillTextField('Display name *', opts.displayName); + await this.selectOption('Catalog item *', opts.catalogItem); + await this.fillTextField('API version *', opts.apiVersion); + } + + async clickRehydrateInstance(displayName: string) { + const row = this.page.locator('table tbody tr', { hasText: displayName }); + await row.getByRole('button', { name: 'Rehydrate instance' }).click(); + } + + async clickDeleteInstance(displayName: string) { + const row = this.page.locator('table tbody tr', { hasText: displayName }); + await row.getByRole('button', { name: 'Delete instance' }).click(); + } + + async verifySuccessSnackbar() { + await expect( + this.page.locator('[class*="MuiAlert-standardSuccess"]').first(), + ).toBeVisible({ timeout: 10000 }); + } + + // ── Shared dialog actions ───────────────────────────────────────────── + + async submitDialog(buttonLabel: string) { + await this.page + .locator('[role="dialog"], [class*="MuiDrawer"]') + .getByRole('button', { name: buttonLabel }) + .click(); + } + + async confirmDelete() { + await this.page + .locator('[role="dialog"]') + .getByRole('button', { name: 'Delete' }) + .click(); + } + + async cancelDialog() { + await this.page + .locator('[role="dialog"], [class*="MuiDrawer"]') + .getByRole('button', { name: 'Cancel' }) + .click(); + } + + async waitForDialogClosed() { + await expect(this.page.locator('[role="dialog"]')).toHaveCount(0, { + timeout: 10000, + }); + } + + async waitForTableRefresh() { + await this.page.waitForLoadState('networkidle'); + await this.page.waitForTimeout(1000); + } + + async getRowsPerPageValue(): Promise { + const rppSelect = this.page + .locator('[role="button"][aria-haspopup="listbox"]') + .filter({ hasText: /rows/ }); + return (await rppSelect.textContent()) ?? ''; + } + + async setRowsPerPage(value: string) { + const rppSelect = this.page + .locator('[role="button"][aria-haspopup="listbox"]') + .filter({ hasText: /rows/ }); + await rppSelect.click(); + await this.page.getByRole('option', { name: value }).click(); + await this.page.waitForTimeout(1000); + } + + async getTableRowTexts(): Promise { + const rows = this.page.locator('table').first().locator('tbody tr'); + return rows.allTextContents(); + } + + async getEmptyRowCount(): Promise { + const texts = await this.getTableRowTexts(); + return texts.filter(t => t.trim() === '').length; + } + + // ── Low-level form helpers ──────────────────────────────────────────── + + private async fillTextField(label: string, value: string) { + const field = this.page.locator( + `label:has-text("${label}") + div input, label:has-text("${label}") + div textarea`, + ); + if ((await field.count()) > 0) { + await field.first().click(); + await field.first().fill(value); + return; + } + const altField = this.page.getByLabel(label); + await altField.click(); + await altField.fill(value); + } + + private async selectOption(label: string, value: string) { + const select = this.page + .locator(`label:has-text("${label}")`) + .locator('..') + .locator('[role="button"], select'); + await select.first().click(); + await this.page.getByRole('option', { name: value }).click(); + } + + private async selectMultipleOptions(label: string, values: string[]) { + const select = this.page + .locator(`label:has-text("${label}")`) + .locator('..') + .locator('[role="button"], select'); + await select.first().click(); + for (const value of values) { + await this.page.getByRole('option', { name: value }).click(); + } + await this.page.keyboard.press('Escape'); + } +} diff --git a/workspaces/dcm/playwright.config.ts b/workspaces/dcm/playwright.config.ts new file mode 100644 index 0000000000..30dcce7b14 --- /dev/null +++ b/workspaces/dcm/playwright.config.ts @@ -0,0 +1,76 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + timeout: 60_000, + + expect: { + timeout: 10_000, + }, + + webServer: process.env.PLAYWRIGHT_URL + ? [] + : [ + { + command: 'yarn start app', + port: 3000, + reuseExistingServer: true, + timeout: 60_000, + }, + { + command: 'yarn start backend', + port: 7007, + reuseExistingServer: true, + timeout: 60_000, + }, + ], + + forbidOnly: !!process.env.CI, + + workers: process.env.CI ? 2 : 1, + + retries: process.env.CI ? 2 : 0, + + reporter: [ + ['list'], + ['html', { open: 'never', outputFolder: 'e2e-test-report' }], + ['junit', { outputFile: 'playwright-results.xml' }], + ], + + use: { + actionTimeout: 0, + baseURL: + process.env.PLAYWRIGHT_URL ?? + (process.env.CI ? 'http://localhost:7007' : 'http://localhost:3000'), + screenshot: 'only-on-failure', + trace: 'on-first-retry', + ignoreHTTPSErrors: true, + }, + + outputDir: 'test-results', + + projects: [ + { + name: 'chromium', + testDir: 'packages/app/e2e-tests', + use: { + channel: 'chrome', + }, + }, + ], +}); From e9ff747d8ca32f85aba42eefb84d8a0a5e664fc1 Mon Sep 17 00:00:00 2001 From: gharden Date: Fri, 29 May 2026 13:49:25 -0400 Subject: [PATCH 2/3] fix(dcm): replace Math.random with globalThis.crypto.getRandomValues Addresses SonarQube security hotspots (typescript:S2245) flagging Math.random() as weak PRNG. Uses globalThis.crypto to satisfy both SonarQube and ESLint no-restricted-globals rule. Co-authored-by: Cursor --- workspaces/dcm/packages/app/e2e-tests/dcm-policies.test.ts | 6 +++++- .../dcm/packages/app/e2e-tests/dcm-regressions.test.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/workspaces/dcm/packages/app/e2e-tests/dcm-policies.test.ts b/workspaces/dcm/packages/app/e2e-tests/dcm-policies.test.ts index 820296c5ba..393cacef17 100644 --- a/workspaces/dcm/packages/app/e2e-tests/dcm-policies.test.ts +++ b/workspaces/dcm/packages/app/e2e-tests/dcm-policies.test.ts @@ -18,7 +18,11 @@ import { test, expect } from '@playwright/test'; import { DcmPage } from './pages/DcmPage'; const suffix = () => Date.now().toString(36).slice(-5); -const uniquePriority = () => String(Math.floor(Math.random() * 900) + 50); +const uniquePriority = () => { + const buf = new Uint32Array(1); + globalThis.crypto.getRandomValues(buf); + return String((buf[0] % 900) + 50); +}; test.describe('DCM Policies CRUD @dcm', () => { let dcm: DcmPage; diff --git a/workspaces/dcm/packages/app/e2e-tests/dcm-regressions.test.ts b/workspaces/dcm/packages/app/e2e-tests/dcm-regressions.test.ts index 3f17c2d8b2..b359c4f3c8 100644 --- a/workspaces/dcm/packages/app/e2e-tests/dcm-regressions.test.ts +++ b/workspaces/dcm/packages/app/e2e-tests/dcm-regressions.test.ts @@ -18,7 +18,11 @@ import { test, expect } from '@playwright/test'; import { DcmPage } from './pages/DcmPage'; const suffix = () => Date.now().toString(36).slice(-5); -const uniquePriority = () => String(Math.floor(Math.random() * 900) + 50); +const uniquePriority = () => { + const buf = new Uint32Array(1); + globalThis.crypto.getRandomValues(buf); + return String((buf[0] % 900) + 50); +}; test.describe('DCM Bug Regression Tests @dcm', () => { let dcm: DcmPage; From 1d16d6ad40eb9c1c8613b00f7d2381b0e05da7a0 Mon Sep 17 00:00:00 2001 From: gharden Date: Fri, 29 May 2026 15:11:49 -0400 Subject: [PATCH 3/3] fix(dcm): update yarn.lock with @playwright/test 1.58.2 Add missing lockfile entries for @playwright/test, playwright, and playwright-core to fix immutable install failure in CI. Co-authored-by: Cursor --- workspaces/dcm/yarn.lock | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/workspaces/dcm/yarn.lock b/workspaces/dcm/yarn.lock index 344ff9b377..7363f7c99b 100644 --- a/workspaces/dcm/yarn.lock +++ b/workspaces/dcm/yarn.lock @@ -6660,6 +6660,7 @@ __metadata: "@backstage/e2e-test-utils": "npm:^0.1.1" "@backstage/repo-tools": "npm:^0.16.2" "@changesets/cli": "npm:^2.27.1" + "@playwright/test": "npm:1.58.2" "@types/jest": "npm:^29.5.12" "@types/node": "npm:^20.0.0" "@types/webpack-env": "npm:^1.18.4" @@ -9196,6 +9197,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:1.58.2": + version: 1.58.2 + resolution: "@playwright/test@npm:1.58.2" + dependencies: + playwright: "npm:1.58.2" + bin: + playwright: cli.js + checksum: 10/58bf90139280a0235eeeb6049e9fb4db6425e98be1bf0cc17913b068eef616cf67be57bfb36dc4cb56bcf116f498ffd0225c4916e85db404b343ea6c5efdae13 + languageName: node + linkType: hard + "@popperjs/core@npm:^2.11.8": version: 2.11.8 resolution: "@popperjs/core@npm:2.11.8" @@ -28462,6 +28474,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.58.2": + version: 1.58.2 + resolution: "playwright-core@npm:1.58.2" + bin: + playwright-core: cli.js + checksum: 10/8a98fcf122167e8703d525db2252de0e3da4ab9110ab6ea9951247e52d846310eb25ea2c805e1b7ccb54b4010c44e5adc3a76aae6da02f34324ccc3e76683bb1 + languageName: node + linkType: hard + +"playwright@npm:1.58.2": + version: 1.58.2 + resolution: "playwright@npm:1.58.2" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.58.2" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10/d89d6c8a32388911b9aff9ee0f1a90076219f15c804f2b287db048b9e9cde182aea3131fac1959051d25189ed4218ec4272b137c83cd7f9cd24781cbc77edd86 + languageName: node + linkType: hard + "pluralize@npm:^8.0.0": version: 8.0.0 resolution: "pluralize@npm:8.0.0"