diff --git a/frontend/e2e/clients/kubernetes-client.ts b/frontend/e2e/clients/kubernetes-client.ts index 042d512c907..8e45e68dab7 100644 --- a/frontend/e2e/clients/kubernetes-client.ts +++ b/frontend/e2e/clients/kubernetes-client.ts @@ -286,8 +286,12 @@ export default class KubernetesClient { async createNamespace(name: string, labels?: Record): Promise { try { - await this.k8sApi.readNamespace({ name }); - return; // already exists + const { status } = await this.k8sApi.readNamespace({ name }); + if (status?.phase === 'Terminating') { + await this.waitForNamespaceDeleted(name); + } else { + return; // already exists and is active + } } catch (err) { if (!isNotFound(err)) { throw err; @@ -636,6 +640,95 @@ export default class KubernetesClient { }); } + async waitForDeploymentReady( + name: string, + namespace: string, + timeoutMs = 120_000, + ): Promise { + const ready = await pollUntil( + async () => { + try { + const deployment = await this.appsApi.readNamespacedDeployment({ name, namespace }); + const status = deployment.status; + const desired = deployment.spec?.replicas ?? 1; + return ( + status?.availableReplicas === desired && + status?.updatedReplicas === desired && + (status?.conditions ?? []).some( + (c) => c.type === 'Available' && c.status === 'True', + ) + ); + } catch { + return false; + } + }, + timeoutMs, + 2_000, + ); + if (!ready) { + const diag = await this.getDeploymentDiagnostics(name, namespace); + throw new Error( + `Deployment ${namespace}/${name} not ready after ${timeoutMs / 1000}s.\n${diag}`, + ); + } + } + + private async getDeploymentDiagnostics(name: string, namespace: string): Promise { + const lines: string[] = []; + try { + const deployment = await this.appsApi.readNamespacedDeployment({ name, namespace }); + const conditions = deployment.status?.conditions ?? []; + lines.push( + `Deployment status: replicas=${deployment.status?.replicas ?? 0}, ` + + `ready=${deployment.status?.readyReplicas ?? 0}, ` + + `available=${deployment.status?.availableReplicas ?? 0}, ` + + `updated=${deployment.status?.updatedReplicas ?? 0}`, + ); + for (const c of conditions) { + lines.push(` condition ${c.type}=${c.status}: ${c.message ?? ''}`); + } + } catch (err) { + lines.push(`Could not read deployment: ${err}`); + } + try { + const pods = await this.k8sApi.listNamespacedPod({ namespace, labelSelector: `app=${name}` }); + for (const pod of pods.items) { + const podName = pod.metadata?.name ?? 'unknown'; + const phase = pod.status?.phase ?? 'Unknown'; + lines.push(`Pod ${podName}: phase=${phase}`); + for (const cs of pod.status?.containerStatuses ?? []) { + const state = cs.state?.waiting + ? `Waiting: ${cs.state.waiting.reason} - ${cs.state.waiting.message ?? ''}` + : cs.state?.terminated + ? `Terminated: ${cs.state.terminated.reason}` + : 'Running'; + lines.push(` container ${cs.name}: ready=${cs.ready}, restarts=${cs.restartCount}, ${state}`); + } + try { + const events = await this.k8sApi.listNamespacedEvent({ + namespace, + fieldSelector: `involvedObject.name=${podName}`, + }); + const recent = events.items + .sort( + (a, b) => + new Date(b.lastTimestamp ?? 0).getTime() - + new Date(a.lastTimestamp ?? 0).getTime(), + ) + .slice(0, 10); + for (const ev of recent) { + lines.push(` event: ${ev.reason} - ${ev.message} (count=${ev.count ?? 1})`); + } + } catch { + lines.push(` Could not fetch events for pod ${podName}`); + } + } + } catch (err) { + lines.push(`Could not list pods: ${err}`); + } + return lines.join('\n'); + } + async deletePod(name: string, namespace: string): Promise { try { await this.k8sApi.deleteNamespacedPod({ name, namespace }); diff --git a/frontend/e2e/pages/base-page.ts b/frontend/e2e/pages/base-page.ts index d386ae6b204..309a66da256 100644 --- a/frontend/e2e/pages/base-page.ts +++ b/frontend/e2e/pages/base-page.ts @@ -126,6 +126,13 @@ export default abstract class BasePage { await this.robustClick(button); } + async waitForEditorReady(): Promise { + await this.page.waitForFunction( + () => !!(window as any).monaco?.editor?.getModels()?.[0], + { timeout: 30_000 }, + ); + } + async getEditorContent(): Promise { return getEditorContent(this.page); } diff --git a/frontend/e2e/pages/cluster-dashboard-page.ts b/frontend/e2e/pages/cluster-dashboard-page.ts index 07fc593bf62..9c3e33f94e0 100644 --- a/frontend/e2e/pages/cluster-dashboard-page.ts +++ b/frontend/e2e/pages/cluster-dashboard-page.ts @@ -42,4 +42,18 @@ export class ClusterDashboardPage extends BasePage { await this.robustClick(this.insightsButton); await expect(this.popover).toBeVisible({ timeout: 10_000 }); } + + async isInsightsDataAvailable(): Promise { + const popover = this.popover; + const timeout = 30_000; + /* eslint-disable no-restricted-syntax */ + const result = await Promise.race([ + popover.getByText('Temporarily unavailable.').waitFor({ state: 'visible', timeout }).then(() => 'no-data' as const), + popover.getByText('Waiting for results.').waitFor({ state: 'visible', timeout }).then(() => 'no-data' as const), + popover.getByText('Disabled.').waitFor({ state: 'visible', timeout }).then(() => 'no-data' as const), + popover.locator('a[href*="console.redhat.com/openshift/insights/advisor"]').first().waitFor({ state: 'visible', timeout }).then(() => 'data' as const), + ]).catch(() => 'no-data' as const); + /* eslint-enable no-restricted-syntax */ + return result === 'data'; + } } diff --git a/frontend/e2e/pages/console-plugin-page.ts b/frontend/e2e/pages/console-plugin-page.ts new file mode 100644 index 00000000000..17cb5306e73 --- /dev/null +++ b/frontend/e2e/pages/console-plugin-page.ts @@ -0,0 +1,80 @@ +import type { Locator } from '@playwright/test'; + +import BasePage from './base-page'; + +export class ConsolePluginPage extends BasePage { + private readonly codeEditor = this.page.locator('.co-code-editor'); + private readonly pfCodeEditor = this.page.locator('.pf-v6-c-code-editor'); + + async navigateToConsolePlugins(): Promise { + await this.goTo( + '/k8s/cluster/operator.openshift.io~v1~Console/cluster/console-plugins', + ); + } + + async navigateToPluginDetails(pluginName: string): Promise { + await this.goTo( + `/k8s/cluster/console.openshift.io~v1~ConsolePlugin/${pluginName}`, + ); + } + + async navigateToPluginManifest(pluginName: string): Promise { + await this.goTo( + `/k8s/cluster/console.openshift.io~v1~ConsolePlugin/${pluginName}/plugin-manifest`, + ); + } + + getPluginNameCell(pluginName: string): Locator { + return this.page.getByTestId(`${pluginName}-name`); + } + + getPluginStatusCell(pluginName: string): Locator { + return this.page.getByTestId(`${pluginName}-status`); + } + + getCodeEditor(): Locator { + return this.codeEditor; + } + + getReadOnlyCodeEditor(): Locator { + return this.pfCodeEditor; + } + + getEmptyBox(): Locator { + return this.page.getByTestId('empty-box'); + } + + async clickEditPluginButton(pluginName: string): Promise { + const row = this.getPluginNameCell(pluginName).locator('xpath=ancestor::tr'); + const editButton = row.getByTestId('edit-console-plugin'); + await this.robustClick(editButton); + } + + async navigateToOverview(): Promise { + await this.goTo('/'); + } + + async navigateToDynamicRoute(id: string): Promise { + await this.goTo(`/dynamic-route-${id}`); + } + + async navigateToTestUtilities(): Promise { + await this.goTo('/test-utility-consumer'); + } + + async navigateToDemoListPage(): Promise { + await this.goTo('/demo-list-page'); + } + + async navigateToK8sApi(): Promise { + await this.goTo('/test-k8sapi'); + } + + async navigateToProjects(): Promise { + await this.goTo('/k8s/cluster/projects'); + } + + async navigateWithQueryParam(queryString: string): Promise { + await this.goTo(`/?${queryString}`); + } +} diff --git a/frontend/e2e/pages/modal-page.ts b/frontend/e2e/pages/modal-page.ts index 1925d4d203b..9065bd0a8a5 100644 --- a/frontend/e2e/pages/modal-page.ts +++ b/frontend/e2e/pages/modal-page.ts @@ -4,9 +4,14 @@ import { expect } from '@playwright/test'; import BasePage from './base-page'; export class ModalPage extends BasePage { + private readonly modalTitle = this.page.getByTestId('modal-title'); private readonly cancelButton = this.page.getByTestId('modal-cancel-action'); private readonly submitButton = this.page.getByTestId('confirm-action'); + getModalTitle(): Locator { + return this.modalTitle; + } + getCancelButton(): Locator { return this.cancelButton; } diff --git a/frontend/e2e/pages/yaml-editor-page.ts b/frontend/e2e/pages/yaml-editor-page.ts index 90af8672689..83b8de0d3af 100644 --- a/frontend/e2e/pages/yaml-editor-page.ts +++ b/frontend/e2e/pages/yaml-editor-page.ts @@ -3,6 +3,8 @@ import { expect } from '@playwright/test'; import BasePage from './base-page'; +const SETTINGS_MODAL_ID = 'edit-yaml-settings-modal'; + export class YamlEditorPage extends BasePage { private readonly codeEditor = this.page.getByTestId('code-editor'); private readonly saveButton = this.page.getByTestId('save-changes'); @@ -10,6 +12,10 @@ export class YamlEditorPage extends BasePage { private readonly yamlError = this.page.getByTestId('yaml-error'); private readonly resourceSidebar = this.page.getByTestId('resource-sidebar'); + async navigateToImportYaml(): Promise { + await this.goTo('/k8s/ns/default/import'); + } + async waitForEditorReady(): Promise { await expect(this.codeEditor).toBeVisible({ timeout: 30_000 }); } @@ -30,6 +36,44 @@ export class YamlEditorPage extends BasePage { return this.yamlError; } + getMonacoEditor(): Locator { + return this.page.locator('.monaco-editor').first(); + } + + getMonacoViewLines(): Locator { + return this.page.locator('.monaco-editor .view-lines').first(); + } + + getSettingsModal(): Locator { + return this.page.locator(`[data-ouia-component-id="${SETTINGS_MODAL_ID}"]`); + } + + getSettingsModalTitle(): Locator { + return this.page.locator(`#${SETTINGS_MODAL_ID}-title`); + } + + getSettingsModalBody(): Locator { + return this.page.locator(`#${SETTINGS_MODAL_ID}-body`); + } + + getFontSizeInput(): Locator { + return this.page + .locator('#ConfigModalItem-font-size') + .locator('input[aria-label="Enter a font size"]'); + } + + getFontSizeIncreaseButton(): Locator { + return this.page + .locator('#ConfigModalItem-font-size') + .locator('button[aria-label="Increase font size"]'); + } + + getFontSizeDecreaseButton(): Locator { + return this.page + .locator('#ConfigModalItem-font-size') + .locator('button[aria-label="Decrease font size"]'); + } + async clickSave(): Promise { await this.robustClick(this.saveButton); } @@ -37,4 +81,42 @@ export class YamlEditorPage extends BasePage { async clickReload(): Promise { await this.robustClick(this.reloadButton); } + + async openSettingsModal(): Promise { + await this.robustClick(this.page.locator('[aria-label="Editor settings"]')); + // eslint-disable-next-line no-restricted-syntax + await this.getSettingsModal().waitFor({ state: 'visible' }); + } + + async closeSettingsModal(): Promise { + await this.robustClick( + this.getSettingsModal().locator('button[aria-label="Close"]'), + ); + } + + async selectTheme(themeName: 'Dark' | 'Light' | 'Use theme setting'): Promise { + const themeSection = this.page.locator('#ConfigModalItem-color-theme'); + await this.robustClick( + themeSection.locator('button[aria-labelledby="ConfigModalItem-color-theme-title"]'), + ); + await this.page.getByText(themeName, { exact: true }).click(); + } + + async setFontSize(size: number): Promise { + const input = this.getFontSizeInput(); + await input.fill(String(size)); + } + + async showSidebar(): Promise { + await this.robustClick(this.page.locator('[aria-label="Show sidebar"]')); + } + + async clickFieldDetailsButton(fieldName: string): Promise { + const fieldHeading = this.page.locator('h5', { hasText: fieldName }); + const listItem = fieldHeading.locator('xpath=ancestor::li'); + const viewDetailsButton = listItem.locator('button.pf-v6-c-button', { + hasText: 'View details', + }); + await this.robustClick(viewDetailsButton); + } } diff --git a/frontend/e2e/tests/console/app/demo-dynamic-plugin.spec.ts b/frontend/e2e/tests/console/app/demo-dynamic-plugin.spec.ts new file mode 100644 index 00000000000..5b3cb0a983f --- /dev/null +++ b/frontend/e2e/tests/console/app/demo-dynamic-plugin.spec.ts @@ -0,0 +1,377 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import yaml from 'js-yaml'; + +import { test, expect } from '../../../fixtures'; +import { ConsolePluginPage } from '../../../pages/console-plugin-page'; +import { DetailsPage } from '../../../pages/details-page'; +import { ListPage } from '../../../pages/list-page'; +import { ModalPage } from '../../../pages/modal-page'; +import { getEditorContent } from '../../../pages/base-page'; + +const PLUGIN_NAME = 'console-demo-plugin'; +const PLUGIN_PULL_SPEC = process.env.PLUGIN_PULL_SPEC; +const IS_LOCAL_DEV = (process.env.WEB_CONSOLE_URL || '').includes('localhost'); +const SHOULD_DEPLOY_PLUGIN = !IS_LOCAL_DEV; + +async function skipIfModelUnavailable(page: import('@playwright/test').Page): Promise { + const errorHeading = page.getByRole('heading', { name: /Error loading/ }); + // eslint-disable-next-line no-restricted-syntax + const hasError = await errorHeading + .waitFor({ state: 'visible', timeout: 10_000 }) + .then( + () => true, + () => false, + ); + if (hasError) { + test.skip(true, 'ConsolePlugin model not available in this environment'); + } +} + +interface ManifestResource { + kind: string; + metadata: { name: string; namespace?: string }; + spec?: Record; +} + +test.describe( + 'Demo dynamic plugin', + { tag: ['@admin', '@dynamic-plugin'] }, + () => { + test.describe.configure({ mode: 'serial' }); + + let consolePluginPage: ConsolePluginPage; + let detailsPage: DetailsPage; + let listPage: ListPage; + let modalPage: ModalPage; + + test.beforeAll(async ({ k8sClient }) => { + test.setTimeout(180_000); + if (SHOULD_DEPLOY_PLUGIN) { + const manifestPath = path.resolve( + import.meta.dirname, + '../../../../../dynamic-demo-plugin/oc-manifest.yaml', + ); + const textManifest = fs.readFileSync(manifestPath, 'utf-8'); + const yamlManifest = yaml.loadAll(textManifest) as ManifestResource[]; + + const deployment = yamlManifest.find(({ kind }) => kind === 'Deployment'); + const service = yamlManifest.find(({ kind }) => kind === 'Service'); + const consolePlugin = yamlManifest.find(({ kind }) => kind === 'ConsolePlugin'); + + if (!deployment || !service || !consolePlugin) { + throw new Error( + 'oc-manifest.yaml is missing required resources: Deployment, Service, or ConsolePlugin', + ); + } + + if (PLUGIN_PULL_SPEC && deployment.spec) { + const templateSpec = ( + (deployment.spec as Record).template as Record + ).spec as Record; + const containers = templateSpec.containers as Array>; + templateSpec.containers = containers.map((container, idx) => + idx === 0 ? { ...container, image: PLUGIN_PULL_SPEC } : container, + ); + } + + const deploymentContainers = ( + ((deployment.spec as Record).template as Record) + .spec as Record + ).containers as Array>; + // eslint-disable-next-line no-console + console.log( + `Deploying ${PLUGIN_NAME} with image: ${deploymentContainers[0]?.image ?? 'unknown'}`, + ); + + await k8sClient.createNamespace(PLUGIN_NAME); + + await k8sClient.appsV1Api.createNamespacedDeployment({ + namespace: PLUGIN_NAME, + body: deployment as unknown as Record, + }); + + await k8sClient.waitForDeploymentReady(PLUGIN_NAME, PLUGIN_NAME); + + await k8sClient.coreV1Api.createNamespacedService({ + namespace: PLUGIN_NAME, + body: service as unknown as Record, + }); + + await k8sClient.createClusterCustomResource( + 'console.openshift.io', + 'v1', + 'consoleplugins', + consolePlugin as unknown as Record, + ); + + // Log ConsolePlugin resource status for CI debugging + try { + const cp = (await k8sClient.customObjectsApi.getClusterCustomObject({ + group: 'console.openshift.io', + version: 'v1', + plural: 'consoleplugins', + name: PLUGIN_NAME, + })) as Record; + // eslint-disable-next-line no-console + console.log(`ConsolePlugin ${PLUGIN_NAME}:`, JSON.stringify(cp.status ?? {}, null, 2)); + } catch (err) { + // eslint-disable-next-line no-console + console.log(`Could not read ConsolePlugin status: ${err}`); + } + } + }); + + test.beforeEach(async ({ page }) => { + consolePluginPage = new ConsolePluginPage(page); + detailsPage = new DetailsPage(page); + listPage = new ListPage(page); + modalPage = new ModalPage(page); + }); + + test.afterAll(async ({ k8sClient }) => { + if (SHOULD_DEPLOY_PLUGIN) { + await k8sClient + .deleteClusterCustomResource( + 'console.openshift.io', + 'v1', + 'consoleplugins', + PLUGIN_NAME, + ) + .catch(() => { + // May already be deleted by the UI test + }); + await k8sClient.deleteNamespace(PLUGIN_NAME); + } + }); + + test('enables the demo plugin and verifies it loads', async ({ page }) => { + test.skip(IS_LOCAL_DEV, 'Plugin enablement is only tested on CI'); + + await test.step('Navigate to console plugins tab', async () => { + await consolePluginPage.navigateToConsolePlugins(); + await expect(consolePluginPage.getPluginNameCell(PLUGIN_NAME)).toBeVisible(); + }); + + await test.step('Enable the plugin if not already enabled', async () => { + const enabledCell = page.getByTestId(`${PLUGIN_NAME}-enabled`); + const alreadyEnabled = (await enabledCell.textContent())?.includes('Enabled'); + if (alreadyEnabled) { + return; + } + await consolePluginPage.clickEditPluginButton(PLUGIN_NAME); + await modalPage.waitForOpen(); + await expect(modalPage.getModalTitle()).toContainText('Console plugin enablement'); + await page.getByTestId('Enable-radio-input').click(); + await modalPage.submit(); + await modalPage.waitForClosed(); + await expect(enabledCell).toContainText('Enabled'); + }); + + await test.step('Verify plugin status is Loaded', async () => { + // After enablement the console auto-reloads before the server has + // reconciled the plugin config, so the plugin is not loaded in that + // session. Navigate to the plugins page and reload until the console + // server picks up the updated config and reports the plugin as Loaded. + await consolePluginPage.navigateToConsolePlugins(); + await expect(page.getByTestId(`${PLUGIN_NAME}-name`)).toBeVisible(); + await expect(async () => { + await page.reload({ waitUntil: 'load' }); + await expect(page.getByTestId(`${PLUGIN_NAME}-status`)).toContainText('Loaded'); + }).toPass({ timeout: 120_000, intervals: [15_000] }); + }); + }); + + test('verifies Dashboard Card nav item', async ({ page }) => { + await consolePluginPage.navigateToOverview(); + const demoDashboardTab = page.getByTestId('horizontal-link-Demo Dashboard'); + await expect(demoDashboardTab).toHaveText('Demo Dashboard'); + await demoDashboardTab.click(); + await expect(page.getByTestId('demo-plugin-dashboard-card')).toContainText( + 'Metrics Dashboard Card example', + ); + await expect(page.locator('div.graph-wrapper')).toBeAttached(); + }); + + test('verifies Dynamic Nav items', async ({ page }) => { + for (const navID of ['1', '2']) { + await test.step(`Dynamic Nav ${navID}`, async () => { + await consolePluginPage.navigateToDynamicRoute(navID); + await expect(page.getByTestId('title')).toContainText(`Dynamic Page ${navID}`); + await expect(page.getByTestId('alert-info')).toContainText('Example info alert'); + await expect(page.getByTestId('alert-warning')).toContainText('Example warning alert'); + await expect(page.getByTestId('hint')).toContainText('Example hint'); + await expect(page.getByTestId('card').first()).toContainText('Example card'); + }); + } + }); + + test('verifies Test Utilities nav item', async ({ page }) => { + await consolePluginPage.navigateToTestUtilities(); + await expect( + page.getByRole('heading', { name: 'Utilities from Dynamic Plugin SDK' }), + ).toBeVisible(); + await expect(page.getByText('Utility: consoleFetchJSON')).toBeVisible(); + await expect(page.getByText('Utility: useToast')).toBeVisible(); + }); + + test('verifies List Page nav item', async ({ page }) => { + const podName = 'openshift-state-metrics'; + await consolePluginPage.navigateToDemoListPage(); + await expect(page.getByTestId('page-heading').locator('h1')).toContainText( + 'OpenShift Pods List Page', + ); + await listPage.filterByNameInput(podName); + await expect(page.getByTestId('resource-row').filter({ hasText: podName })).toBeVisible(); + }); + + test('verifies K8s API nav item', async ({ page }) => { + const apiIDs = ['k8sCreate', 'k8sGet', 'k8sPatch', 'k8sUpdate', 'k8sList', 'k8sDelete']; + await consolePluginPage.navigateToK8sApi(); + await expect( + page.getByRole('heading', { name: 'K8s API from Dynamic Plugin SDK' }), + ).toBeVisible(); + for (const apiID of apiIDs) { + await test.step(`K8s API: ${apiID}`, async () => { + await expect( + page.getByRole('button', { name: apiID, exact: true }), + ).toBeVisible(); + }); + } + }); + + test('shows Dynamic Plugins in Cluster Overview Status card', async ({ page }) => { + await consolePluginPage.navigateToOverview(); + await page.getByRole('button', { name: 'Dynamic Plugins' }).click(); + await expect(page.getByText('Loaded plugins')).toBeVisible(); + const popover = page.locator('.pf-v6-c-popover'); + await expect( + popover.locator('a', { hasText: 'View all' }), + ).toHaveAttribute( + 'href', + '/k8s/cluster/operator.openshift.io~v1~Console/cluster/console-plugins', + ); + }); + + test('shows Dynamic Plugins in About modal', async ({ page }) => { + await consolePluginPage.navigateToOverview(); + await page.getByTestId('help-dropdown-toggle').click(); + await page.getByText('About', { exact: true }).click(); + await expect(page.locator('dt', { hasText: 'Dynamic plugins' })).toBeVisible(); + await expect(page.getByText('console-demo-plugin (0.0.0)')).toBeVisible(); + await page.getByRole('button', { name: 'Close Dialog' }).click(); + }); + + test('verifies extension point for customized create project modal', async ({ page }) => { + await consolePluginPage.navigateToProjects(); + await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible(); + await page.getByRole('button', { name: 'Create Project' }).click(); + await expect( + page.getByText('This modal is created with an extension'), + ).toBeVisible(); + await page.getByRole('button', { name: 'Cancel' }).click(); + }); + + test('displays manifest tab in ConsolePlugin details page', async ({ page }) => { + await consolePluginPage.navigateToPluginDetails(PLUGIN_NAME); + await skipIfModelUnavailable(page); + await expect(detailsPage.getPageHeading()).toContainText(PLUGIN_NAME); + await expect( + page.getByTestId('horizontal-link-Plugin manifest'), + ).toBeVisible(); + }); + + test('navigates to manifest tab and displays read-only editor with JSON', async ({ page }) => { + await consolePluginPage.navigateToPluginManifest(PLUGIN_NAME); + await expect(page).toHaveURL(/\/plugin-manifest/); + await skipIfModelUnavailable(page); + + await expect( + page.getByTestId('horizontal-link-Plugin manifest'), + ).toHaveClass(/pf-m-current/); + + const codeEditor = consolePluginPage.getCodeEditor(); + const emptyBox = consolePluginPage.getEmptyBox(); + const heading = detailsPage.getPageHeading(); + await expect(codeEditor.or(emptyBox).or(heading).first()).toBeVisible(); + }); + + test('manifest tab shows read-only editor when manifest is available', async ({ page }) => { + await consolePluginPage.navigateToPluginManifest(PLUGIN_NAME); + await skipIfModelUnavailable(page); + + const codeEditor = consolePluginPage.getCodeEditor(); + // eslint-disable-next-line no-restricted-syntax + const hasEditor = await codeEditor + .waitFor({ state: 'visible', timeout: 5_000 }) + .then( + () => true, + () => false, + ); + test.skip(!hasEditor, 'Code editor not present — manifest not available'); + + await expect(consolePluginPage.getReadOnlyCodeEditor()).toHaveClass(/pf-m-read-only/); + const content = await getEditorContent(page); + expect(content).toContain('"name"'); + }); + + test('console plugin proxy copies plugin service response status code', async ({ page }) => { + test.skip(IS_LOCAL_DEV, 'Proxy test is only run on CI'); + + const pluginResponse = await page.request.get( + `/api/plugins/${PLUGIN_NAME}/plugin-manifest.json`, + ); + expect(pluginResponse.status()).toBe(200); + }); + + test('allows disabling dynamic plugins through a query parameter', async ({ page }) => { + await test.step('Disable non-existing plugin makes no changes', async () => { + await consolePluginPage.navigateWithQueryParam('disable-plugins=foo,bar'); + await expect(page.locator('#page-sidebar')).toContainText('Dynamic Nav'); + }); + + await test.step('Disable one plugin', async () => { + await consolePluginPage.navigateWithQueryParam('disable-plugins=console-demo-plugin'); + await expect(page.locator('#page-sidebar')).not.toContainText('Dynamic Nav'); + }); + + await test.step('Disable all plugins', async () => { + await consolePluginPage.navigateWithQueryParam('disable-plugins'); + await expect(page.locator('#page-sidebar')).not.toContainText('Dynamic Nav'); + }); + }); + + test('disables the demo plugin and deletes it', async ({ page }) => { + test.skip(IS_LOCAL_DEV, 'Plugin disablement is only tested on CI'); + + await test.step('Navigate to console plugins tab', async () => { + await consolePluginPage.navigateToConsolePlugins(); + await expect(consolePluginPage.getPluginNameCell(PLUGIN_NAME)).toBeVisible(); + }); + + await test.step('Disable the plugin', async () => { + await consolePluginPage.clickEditPluginButton(PLUGIN_NAME); + await modalPage.waitForOpen(); + await page.getByTestId('Disable-radio-input').click(); + await modalPage.submit(); + await modalPage.waitForClosed(); + }); + + await test.step('Verify plugin is disabled', async () => { + const row = consolePluginPage.getPluginNameCell(PLUGIN_NAME).locator('xpath=ancestor::tr'); + await expect(row.getByTestId('edit-console-plugin')).toContainText('Disabled'); + await expect( + consolePluginPage.getPluginStatusCell(PLUGIN_NAME), + ).toContainText('-'); + }); + + await test.step('Delete the ConsolePlugin', async () => { + await consolePluginPage.getPluginNameCell(PLUGIN_NAME).locator('a').click(); + await expect(detailsPage.getPageHeading()).toContainText(PLUGIN_NAME); + await detailsPage.clickPageAction('Delete ConsolePlugin'); + await modalPage.waitForOpen(); + await modalPage.submit(); + }); + }); + }, +); diff --git a/frontend/e2e/tests/console/app/poll-console-updates.spec.ts b/frontend/e2e/tests/console/app/poll-console-updates.spec.ts new file mode 100644 index 00000000000..2f3860df713 --- /dev/null +++ b/frontend/e2e/tests/console/app/poll-console-updates.spec.ts @@ -0,0 +1,146 @@ +import { test, expect } from '../../../fixtures'; + +const CHECK_UPDATES_URL = '**/api/check-updates'; +const PLUGIN_NAME = 'console-demo-plugin'; +const PLUGIN_NAME2 = 'console-demo-plugin2'; +const PLUGIN_MANIFEST_URL = `**/api/plugins/${PLUGIN_NAME}/plugin-manifest.json`; +const PLUGIN_MANIFEST_URL2 = `**/api/plugins/${PLUGIN_NAME2}/plugin-manifest.json`; +const HASH_DEFAULT = 'hash'; +const PLUGINS_DEFAULT: string[] = []; + +const UPDATES_DEFAULT = { consoleCommit: HASH_DEFAULT, plugins: PLUGINS_DEFAULT }; +const UPDATES_NEW_COMMIT = { consoleCommit: 'newhash', plugins: PLUGINS_DEFAULT }; +const UPDATES_NEW_PLUGIN = { consoleCommit: HASH_DEFAULT, plugins: [PLUGIN_NAME] }; +const UPDATES_NEW_PLUGIN2 = { + consoleCommit: HASH_DEFAULT, + plugins: [PLUGIN_NAME, PLUGIN_NAME2], +}; +const PLUGIN_MANIFEST_DEFAULT = { name: PLUGIN_NAME, version: '0.0.0' }; +const PLUGIN_MANIFEST_DEFAULT2 = { name: PLUGIN_NAME2, version: '0.0.0' }; +const PLUGIN_MANIFEST_NEW_VERSION = { name: PLUGIN_NAME, version: '1.0.0' }; + +test.describe('PollConsoleUpdates', { tag: ['@admin'] }, () => { + test('triggers the console update toast when consoleCommit changes', async ({ page }) => { + let resolveFirst: () => void; + const firstIntercepted = new Promise((r) => { + resolveFirst = r; + }); + + await page.route(CHECK_UPDATES_URL, async (route) => { + await route.fulfill({ json: UPDATES_DEFAULT }); + resolveFirst(); + }); + await page.goto('/'); + await firstIntercepted; + + await page.route(CHECK_UPDATES_URL, (route) => + route.fulfill({ json: UPDATES_NEW_COMMIT }), + ); + + await expect(page.getByTestId('refresh-web-console')).toBeVisible({ timeout: 300_000 }); + }); + + test('triggers the console update toast when a plugin is added', async ({ page }) => { + let resolveDefault: () => void; + const defaultIntercepted = new Promise((r) => { + resolveDefault = r; + }); + + await page.route(CHECK_UPDATES_URL, async (route) => { + await route.fulfill({ json: UPDATES_DEFAULT }); + resolveDefault(); + }); + await page.goto('/'); + await defaultIntercepted; + + await page.route(PLUGIN_MANIFEST_URL, (route) => route.abort()); + await page.route(CHECK_UPDATES_URL, (route) => + route.fulfill({ json: UPDATES_NEW_PLUGIN }), + ); + + await expect(page.getByTestId('refresh-web-console')).not.toBeAttached({ + timeout: 10_000, + }); + + await page.route(PLUGIN_MANIFEST_URL, (route) => + route.fulfill({ json: PLUGIN_MANIFEST_DEFAULT }), + ); + + await expect(page.getByTestId('refresh-web-console')).toBeVisible({ timeout: 300_000 }); + }); + + test('triggers the console update toast when a plugin is added and a different plugin endpoint is erroring', async ({ + page, + }) => { + await page.route(PLUGIN_MANIFEST_URL, (route) => route.abort()); + await page.route(CHECK_UPDATES_URL, (route) => + route.fulfill({ json: UPDATES_NEW_PLUGIN }), + ); + await page.goto('/'); + + // Wait for the first check-updates poll to establish baseline state + await page.waitForResponse((resp) => resp.url().includes('/api/check-updates')); + + await expect(page.getByTestId('refresh-web-console')).not.toBeAttached({ + timeout: 10_000, + }); + + // Now introduce a second plugin — plugin1 manifest still errors, plugin2 manifest also errors + await page.route(PLUGIN_MANIFEST_URL2, (route) => route.abort()); + await page.route(CHECK_UPDATES_URL, (route) => + route.fulfill({ json: UPDATES_NEW_PLUGIN2 }), + ); + + // Wait for the app to poll and see the new plugin list + await page.waitForResponse((resp) => resp.url().includes('/api/check-updates')); + + await expect(page.getByTestId('refresh-web-console')).not.toBeAttached({ + timeout: 10_000, + }); + + // Make plugin2 manifest succeed — toast should appear + await page.route(PLUGIN_MANIFEST_URL2, (route) => + route.fulfill({ json: PLUGIN_MANIFEST_DEFAULT2 }), + ); + + await expect(page.getByTestId('refresh-web-console')).toBeVisible({ timeout: 300_000 }); + }); + + test('triggers the console update toast when a plugin is removed', async ({ page }) => { + await page.route(CHECK_UPDATES_URL, (route) => + route.fulfill({ json: UPDATES_NEW_PLUGIN }), + ); + await page.route(PLUGIN_MANIFEST_URL, (route) => + route.fulfill({ json: PLUGIN_MANIFEST_DEFAULT }), + ); + await page.goto('/'); + + await page.waitForResponse((resp) => resp.url().includes('/api/check-updates')); + + await page.route(CHECK_UPDATES_URL, (route) => + route.fulfill({ json: UPDATES_DEFAULT }), + ); + + await expect(page.getByTestId('refresh-web-console')).toBeVisible({ timeout: 300_000 }); + }); + + test('triggers the console update toast when a plugin version changes', async ({ page }) => { + // Serve the old version for the first 2 manifest fetches, then switch to the new version. + // The component needs at least one render cycle with the old version recorded as + // prevPluginManifestsData before it can detect the version change. + let manifestFetchCount = 0; + await page.route(CHECK_UPDATES_URL, (route) => + route.fulfill({ json: UPDATES_NEW_PLUGIN }), + ); + await page.route(PLUGIN_MANIFEST_URL, (route) => { + manifestFetchCount++; + if (manifestFetchCount <= 2) { + return route.fulfill({ json: PLUGIN_MANIFEST_DEFAULT }); + } + return route.fulfill({ json: PLUGIN_MANIFEST_NEW_VERSION }); + }); + await page.goto('/'); + + await expect(page.getByTestId('refresh-web-console')).toBeVisible({ timeout: 300_000 }); + }); +}); diff --git a/frontend/e2e/tests/console/app/yaml-editor.spec.ts b/frontend/e2e/tests/console/app/yaml-editor.spec.ts new file mode 100644 index 00000000000..5652e52bc8a --- /dev/null +++ b/frontend/e2e/tests/console/app/yaml-editor.spec.ts @@ -0,0 +1,169 @@ +import { test, expect } from '../../../fixtures'; +import { warmupSPA } from '../../../pages/base-page'; +import { DetailsPage } from '../../../pages/details-page'; +import { ListPage } from '../../../pages/list-page'; +import { YamlEditorPage } from '../../../pages/yaml-editor-page'; + +const YAML_SAMPLE = `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: default +data: + key: value`; + +test.describe('YAML Editor Settings', { tag: ['@admin', '@yaml-editor'] }, () => { + let yamlEditorPage: YamlEditorPage; + + test.beforeEach(async ({ page }) => { + await warmupSPA(page); + yamlEditorPage = new YamlEditorPage(page); + await yamlEditorPage.navigateToImportYaml(); + await yamlEditorPage.waitForEditorReady(); + }); + + test('should open and close the editor settings modal', async () => { + await yamlEditorPage.openSettingsModal(); + await expect(yamlEditorPage.getSettingsModal()).toBeVisible(); + await expect(yamlEditorPage.getSettingsModalTitle()).toContainText('Editor settings'); + await expect(yamlEditorPage.getSettingsModalBody()).toBeVisible(); + + await yamlEditorPage.closeSettingsModal(); + await expect(yamlEditorPage.getSettingsModal()).not.toBeAttached(); + }); + + test('should toggle theme to Dark mode', async () => { + await yamlEditorPage.openSettingsModal(); + await yamlEditorPage.selectTheme('Dark'); + await expect(yamlEditorPage.getMonacoEditor()).toHaveClass(/vs-dark/); + await yamlEditorPage.closeSettingsModal(); + }); + + test('should toggle theme to Light mode', async () => { + await yamlEditorPage.openSettingsModal(); + await yamlEditorPage.selectTheme('Light'); + await expect(yamlEditorPage.getMonacoEditor()).toHaveClass(/\bvs(?!-)\b/); + await yamlEditorPage.closeSettingsModal(); + }); + + test('should revert to default theme setting', async () => { + await yamlEditorPage.openSettingsModal(); + await yamlEditorPage.selectTheme('Dark'); + await expect(yamlEditorPage.getMonacoEditor()).toHaveClass(/vs-dark/); + await yamlEditorPage.selectTheme('Use theme setting'); + await expect(yamlEditorPage.getMonacoEditor()).not.toHaveClass(/vs-dark/); + await yamlEditorPage.closeSettingsModal(); + }); + + test('should increase font size', async () => { + await yamlEditorPage.setEditorContent(YAML_SAMPLE); + await yamlEditorPage.openSettingsModal(); + + const initialSize = Number(await yamlEditorPage.getFontSizeInput().inputValue()); + await yamlEditorPage.getFontSizeIncreaseButton().click(); + await yamlEditorPage.getFontSizeIncreaseButton().click(); + + await expect(yamlEditorPage.getFontSizeInput()).toHaveValue(String(initialSize + 2)); + await expect(yamlEditorPage.getMonacoViewLines()).toHaveCSS( + 'font-size', + `${initialSize + 2}px`, + ); + await yamlEditorPage.closeSettingsModal(); + }); + + test('should decrease font size', async () => { + await yamlEditorPage.setEditorContent(YAML_SAMPLE); + await yamlEditorPage.openSettingsModal(); + + const initialSize = Number(await yamlEditorPage.getFontSizeInput().inputValue()); + await yamlEditorPage.getFontSizeDecreaseButton().click(); + + await expect(yamlEditorPage.getFontSizeInput()).toHaveValue(String(initialSize - 1)); + await expect(yamlEditorPage.getMonacoViewLines()).toHaveCSS( + 'font-size', + `${initialSize - 1}px`, + ); + await yamlEditorPage.closeSettingsModal(); + }); + + test('should not decrease font size below minimum (5px)', async () => { + await yamlEditorPage.openSettingsModal(); + await yamlEditorPage.setFontSize(5); + await expect(yamlEditorPage.getFontSizeDecreaseButton()).toBeDisabled(); + await yamlEditorPage.closeSettingsModal(); + }); + + test('should allow manual font size input', async () => { + await yamlEditorPage.setEditorContent(YAML_SAMPLE); + await yamlEditorPage.openSettingsModal(); + await yamlEditorPage.setFontSize(18); + + await expect(yamlEditorPage.getFontSizeInput()).toHaveValue('18'); + await expect(yamlEditorPage.getMonacoViewLines()).toHaveCSS('font-size', '18px'); + await yamlEditorPage.closeSettingsModal(); + }); + + test('should persist settings after modal close and reopen', async () => { + await yamlEditorPage.openSettingsModal(); + await yamlEditorPage.selectTheme('Dark'); + await yamlEditorPage.setFontSize(16); + await yamlEditorPage.closeSettingsModal(); + + await yamlEditorPage.openSettingsModal(); + await expect(yamlEditorPage.getMonacoEditor()).toHaveClass(/vs-dark/); + await expect(yamlEditorPage.getFontSizeInput()).toHaveValue('16'); + await yamlEditorPage.closeSettingsModal(); + }); + + test('should persist user settings across pages', async ({ page }) => { + const listPage = new ListPage(page); + const detailsPage = new DetailsPage(page); + + await test.step('Set custom settings on import YAML page', async () => { + await yamlEditorPage.openSettingsModal(); + await yamlEditorPage.selectTheme('Light'); + await yamlEditorPage.setFontSize(20); + await yamlEditorPage.closeSettingsModal(); + + await expect(yamlEditorPage.getMonacoEditor()).toHaveClass(/\bvs(?!-)\b/); + await expect(yamlEditorPage.getMonacoViewLines()).toHaveCSS('font-size', '20px'); + }); + + await test.step('Navigate to a pod YAML page', async () => { + await page.goto('/k8s/ns/openshift-console/pods'); + await expect(listPage.getDataViewTable()).toBeVisible({ timeout: 60_000 }); + await listPage.clickFirstLinkInFirstRow(); + await detailsPage.selectTab('YAML'); + await expect(yamlEditorPage.getMonacoEditor()).toBeVisible({ timeout: 30_000 }); + }); + + await test.step('Verify settings persisted across page navigation', async () => { + await expect(yamlEditorPage.getMonacoEditor()).toHaveClass(/\bvs(?!-)\b/); + await expect(yamlEditorPage.getMonacoViewLines()).toHaveCSS('font-size', '20px'); + }); + }); +}); + +test.describe('YAML editor sidebar', { tag: ['@admin', '@yaml-editor'] }, () => { + test('should show possible enum values in yaml sidebar', async ({ page }) => { + await warmupSPA(page); + const yamlEditorPage = new YamlEditorPage(page); + + await test.step('Navigate to downloads deployment YAML', async () => { + await page.goto('/k8s/ns/openshift-console/deployments/downloads/yaml'); + await yamlEditorPage.waitForEditorReady(); + }); + + await test.step('Show sidebar', async () => { + await yamlEditorPage.showSidebar(); + }); + + await test.step('Navigate to spec > strategy and verify enum values', async () => { + await expect(page.getByRole('tab', { name: 'Schema' })).toBeVisible(); + await yamlEditorPage.clickFieldDetailsButton('spec'); + await yamlEditorPage.clickFieldDetailsButton('strategy'); + await expect(page.getByText('Allowed values:')).toBeVisible(); + await expect(page.getByText('Recreate, RollingUpdate')).toBeVisible(); + }); + }); +}); diff --git a/frontend/e2e/tests/console/dashboards/insights-popup.spec.ts b/frontend/e2e/tests/console/dashboards/insights-popup.spec.ts index 97ef7988a03..8948d2d1e3f 100644 --- a/frontend/e2e/tests/console/dashboards/insights-popup.spec.ts +++ b/frontend/e2e/tests/console/dashboards/insights-popup.spec.ts @@ -40,20 +40,24 @@ test.describe('Insights Popup on Cluster Dashboard', { tag: ['@admin'] }, () => test('renders severity links pointing to the correct Red Hat Insights advisor URL', async () => { await dashboard.openInsightsPopup(); + const dataAvailable = await dashboard.isInsightsDataAvailable(); + test.skip(!dataAvailable, 'Insights data is not available on this cluster'); const advisorLinks = dashboard .getPopover() .locator('a[href*="console.redhat.com/openshift/insights/advisor"]'); - await expect(advisorLinks.first()).toBeVisible(); + await expect(advisorLinks.first()).toBeVisible({ timeout: 40_000 }); await expect(advisorLinks.first()).toHaveAttribute('target', '_blank'); }); test('severity links include total_risk query parameter', async () => { await dashboard.openInsightsPopup(); + const dataAvailable = await dashboard.isInsightsDataAvailable(); + test.skip(!dataAvailable, 'Insights data is not available on this cluster'); const riskLinks = dashboard.getPopover().locator('a[href*="total_risk="]'); + await expect(riskLinks.first()).toBeVisible(); const count = await riskLinks.count(); - expect(count).toBeGreaterThan(0); for (let i = 0; i < count; i++) { const href = await riskLinks.nth(i).getAttribute('href'); const totalRisk = new URL(href, 'https://placeholder').searchParams.get('total_risk'); @@ -63,6 +67,8 @@ test.describe('Insights Popup on Cluster Dashboard', { tag: ['@admin'] }, () => test('shows advisor recommendations link', async () => { await dashboard.openInsightsPopup(); + const dataAvailable = await dashboard.isInsightsDataAvailable(); + test.skip(!dataAvailable, 'Insights data is not available on this cluster'); const popover = dashboard.getPopover(); const advisorLink = popover.getByText(/View (all recommendations|more) in Red Hat Lightspeed Advisor/); diff --git a/frontend/e2e/tests/webterminal/web-terminal-admin.spec.ts b/frontend/e2e/tests/webterminal/web-terminal-admin.spec.ts index 083270a0054..c3bbf0d4bc9 100644 --- a/frontend/e2e/tests/webterminal/web-terminal-admin.spec.ts +++ b/frontend/e2e/tests/webterminal/web-terminal-admin.spec.ts @@ -2,6 +2,7 @@ import type { Page } from '@playwright/test'; import { test, expect } from '../../fixtures'; import type KubernetesClient from '../../clients/kubernetes-client'; +import { getEditorContent } from '../../pages/base-page'; import { WebTerminalPage } from '../../pages/web-terminal-page'; import { ensureWebTerminalOperatorInstalled, @@ -37,7 +38,8 @@ async function verifyDevWorkspaceUid( expect(uid).toBeTruthy(); await webTerminal.navigateToDevWorkspaceYaml(namespace, devWsName); - await expect(webTerminal.getMonacoEditor()).toContainText(uid, { timeout: 30_000 }); + const content = await getEditorContent(page); + expect(content).toContain(uid); } test.describe('Web Terminal for Admin user', () => { diff --git a/frontend/packages/console-shared/src/components/modals/ConsolePluginModal.tsx b/frontend/packages/console-shared/src/components/modals/ConsolePluginModal.tsx index d95a27a7c62..85768354aaf 100644 --- a/frontend/packages/console-shared/src/components/modals/ConsolePluginModal.tsx +++ b/frontend/packages/console-shared/src/components/modals/ConsolePluginModal.tsx @@ -45,6 +45,7 @@ const ConsolePluginModal = (props: ConsolePluginModalProps) => { : t('Console plugin enablement') } labelId="console-plugin-modal-title" + data-test="modal-title" data-test-id="modal-title" /> @@ -85,7 +86,12 @@ const ConsolePluginModal = (props: ConsolePluginModalProps) => { > {t('Save')} - diff --git a/frontend/packages/integration-tests/tests/app/demo-dynamic-plugin.cy.ts b/frontend/packages/integration-tests/tests/app/demo-dynamic-plugin.cy.ts deleted file mode 100644 index 241d422edad..00000000000 --- a/frontend/packages/integration-tests/tests/app/demo-dynamic-plugin.cy.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { safeLoadAll } from 'js-yaml'; -import { checkErrors } from '../../support'; -import { isLocalDevEnvironment } from '../../views/common'; -import { detailsPage } from '../../views/details-page'; -import { listPage } from '../../views/list-page'; -import { masthead } from '../../views/masthead'; -import { modal } from '../../views/modal'; -import { nav } from '../../views/nav'; -import { getEditorContent } from '../../views/yaml-editor'; - -const PLUGIN_NAME = 'console-demo-plugin'; -const PLUGIN_PATH = '../../../dynamic-demo-plugin'; -const PLUGIN_PULL_SPEC = Cypress.expose('PLUGIN_PULL_SPEC'); -/* The update wait is the value to wait for the poll of /api/check-updates to return with the updated list of plugins - after the plugin is enabled and loaded. This wait will be longer on ci than when debugging locally. */ -/* - These tests are meant to: - 1. show how to test a dynamic plugin using demo as the plugin instance - 2. run locally: - 2a. build the plugin locally, and run the server - 2b. using bridge running with the plugin arguments that point to the local dynamic plugin server and i18n namespace - e.g., ./bin/bridge -plugins=console-demo-plugin=http://localhost:9001 -i18n-namespaces=plugin__console-demo-plugin - 2c. will not use all workload definitions defined in the yaml (not using the env variable for pull spec) - 3. run on ci: - 3a. ci will build the dynamic plugin and provide the pullspec in the env var: CYPRESS_PLUGIN_PULL_SPEC - 3b. that pull spec will be used to create the deployment on the cluster - 4. the scaffolding should remain the same except modifying the constants above - */ - -const enableDemoPlugin = (enable: boolean) => { - // find console demo plugin and enable it - cy.visit('k8s/cluster/operator.openshift.io~v1~Console/cluster/console-plugins'); - cy.url().should( - 'include', - 'k8s/cluster/operator.openshift.io~v1~Console/cluster/console-plugins', - ); - cy.get('.co-resource-item__resource-name').byLegacyTestID(PLUGIN_NAME).should('be.visible'); - cy.byLegacyTestID(PLUGIN_NAME) - .parents('tr') - .within(() => { - cy.byTestID('edit-console-plugin').contains(enable ? 'Disabled' : 'Enabled'); - cy.byTestID('edit-console-plugin').click(); - }); - modal.shouldBeOpened(); - cy.contains('Cancel'); - modal.modalTitleShouldContain('Console plugin enablement'); - cy.byTestID(enable ? 'Enable-radio-input' : 'Disable-radio-input').click(); - modal.submit(); - modal.shouldBeClosed(); - cy.byLegacyTestID(PLUGIN_NAME) - .parents('tr') - .within(() => { - cy.byTestID('edit-console-plugin').contains(enable ? 'Enabled' : 'Disabled'); - }); - cy.log(`Running plugin test on ci using PLUGIN_PULL_SPEC: ${PLUGIN_PULL_SPEC}`); - cy.byTestID(`${PLUGIN_NAME}-status`) - .should('include.text', enable ? 'Loaded' : '-') - .then(() => { - if (!enable) { - cy.byLegacyTestID(PLUGIN_NAME).click(); - detailsPage.titleShouldContain(PLUGIN_NAME); - detailsPage.clickPageActionFromDropdown('Delete ConsolePlugin'); - modal.shouldBeOpened(); - modal.submit(); - } - }); -}; - -const dynamicNavTest = (navID: string) => { - nav.sidenav.clickNavLink(['Demo Plugin', `Dynamic Nav ${navID}`]); - cy.byTestID('title').should('contain', `Dynamic Page ${navID}`); - cy.byTestID('alert-info').should('contain', 'Example info alert'); - cy.byTestID('alert-warning').should('contain', 'Example warning alert'); - cy.byTestID('hint').should('contain', 'Example hint'); - cy.byTestID('card').should('contain', 'Example card'); -}; - -const k8sAPINavTest = (apiID: string) => { - cy.byButtonText(apiID).click(); - cy.get('test-k8api-error').should('not.exist'); - cy.get(`test-k8s-${apiID}`).should('not.be.empty'); -}; -if (!Cypress.expose('OPENSHIFT_CI') || Cypress.expose('PLUGIN_PULL_SPEC')) { - describe('Demo dynamic plugin test', () => { - before(() => { - cy.login(); - cy.createProjectWithCLI(PLUGIN_NAME); - cy.readFile(`${PLUGIN_PATH}/oc-manifest.yaml`).then((textManifest) => { - const yamlManifest = safeLoadAll(textManifest); - const deployment = yamlManifest.find(({ kind }) => kind === 'Deployment'); - - if (!isLocalDevEnvironment && PLUGIN_PULL_SPEC) { - console.log('this is not a local env, setting the pull spec for the deployment'); - deployment.spec.template.spec.containers[0].image = PLUGIN_PULL_SPEC; - const service = yamlManifest.find(({ kind }) => kind === 'Service'); - const consolePlugin = yamlManifest.find(({ kind }) => kind === 'ConsolePlugin'); - cy.exec(` echo '${JSON.stringify(deployment)}' | oc create -f -`, { - failOnNonZeroExit: false, - }) - .its('stdout') - .should('contain', 'created') - .then(() => - cy - .exec(` echo '${JSON.stringify(service)}' | oc create -f -`, { - failOnNonZeroExit: false, - }) - .then((result) => { - console.log('Error: ', result.stderr); - console.log('Success: ', result.stdout); - }) - .its('stdout') - .should('contain', 'created'), - ) - .then(() => - cy - .exec(` echo '${JSON.stringify(consolePlugin)}' | oc create -f -`, { - failOnNonZeroExit: false, - }) - .then((result) => { - console.log('Error: ', result.stderr); - console.log('Success: ', result.stdout); - }) - .its('stdout') - .should('contain', 'created'), - ) - .then(() => { - cy.visit(`/k8s/ns/${PLUGIN_NAME}/deployments`); - listPage.dvRows.shouldBeLoaded(); - listPage.dvFilter.byName(PLUGIN_NAME); - listPage.dvRows.shouldExist(PLUGIN_NAME); - enableDemoPlugin(true); - }); - } else { - console.log('this IS A local env, not setting the pull spec for the deployment'); - } - }); - }); - - afterEach(() => { - checkErrors(); - }); - - after(() => { - if (!isLocalDevEnvironment && PLUGIN_PULL_SPEC) { - enableDemoPlugin(false); - } - cy.deleteProjectWithCLI(PLUGIN_NAME); - }); - - it(`test Dashboard Card nav item`, () => { - nav.sidenav.clickNavLink(['Home', `Overview`]); - cy.byLegacyTestID('horizontal-link-Demo Dashboard') - .should('have.text', 'Demo Dashboard') - .click(); - cy.byTestID('demo-plugin-dashboard-card').should('contain', 'Metrics Dashboard Card example'); - cy.get('div.graph-wrapper').should('exist'); - }); - - it(`test Dynamic Nav items`, () => { - const dynamicNavIDs = ['1', '2']; - dynamicNavIDs.forEach((id) => dynamicNavTest(id)); - }); - - it(`test Test Utilities nav item`, () => { - nav.sidenav.clickNavLink(['Demo Plugin', 'Test Utilities']); - cy.byTestID('test-utilities-title').should('contain', 'Utilities from Dynamic Plugin SDK'); - cy.byTestID('test-utility-card').should('contain', 'Utility: consoleFetchJSON'); - cy.byTestID('test-utility-fetch').should('not.be.empty'); - }); - - it(`test List Page nav item`, () => { - const podName = 'openshift-state-metrics'; - nav.sidenav.clickNavLink(['Demo Plugin', 'List Page']); - listPage.titleShouldHaveText('OpenShift Pods List Page'); - listPage.rows.shouldBeLoaded(); - listPage.filter.byName(podName); - listPage.rows.shouldExist(podName); - }); - - it(`test K8s API nav item`, () => { - const apiIDs = ['k8sCreate', 'k8sGet', 'k8sPatch', 'k8sUpdate', 'k8sList', 'k8sDelete']; - nav.sidenav.clickNavLink(['Demo Plugin', 'K8s API']); - cy.byTestID('test-k8sapi-title').should('contain', 'K8s API from Dynamic Plugin SDK'); - apiIDs.forEach((id) => k8sAPINavTest(id)); - }); - - it('add Dynamic Plugins to Cluster Overview Status card', () => { - nav.sidenav.clickNavLink(['Home', 'Overview']); - cy.get('button[data-test="Dynamic Plugins"]').click(); - cy.contains('Loaded plugins').should('exist'); - cy.get('.pf-v6-c-popover').within(() => { - cy.get('a:contains(View all)').should( - 'have.attr', - 'href', - '/k8s/cluster/operator.openshift.io~v1~Console/cluster/console-plugins', - ); - }); - }); - - it('add Dynamic Plugins in About modal', () => { - masthead.clickMastheadLink('help-dropdown-toggle'); - cy.get('span').contains('About').click(); - cy.get('dt').contains('Dynamic plugins').should('exist'); - cy.contains('console-demo-plugin (0.0.0)').should('exist'); - cy.get('button[aria-label="Close Dialog"]').click(); - }); - - it('add extension point to enable customized create project modal', () => { - nav.sidenav.clickNavLink(['Home', 'Projects']); - listPage.dvRows.shouldBeLoaded(); - listPage.clickCreateYAMLbutton(); - cy.get('div').contains('This modal is created with an extension').should('exist'); - cy.byButtonText('Cancel').click(); - }); - - it('should display manifest tab in ConsolePlugin details page', () => { - // Navigate to the demo plugin details page - cy.visit(`/k8s/cluster/console.openshift.io~v1~ConsolePlugin/${PLUGIN_NAME}`); - - // Verify we're on the plugin details page - detailsPage.titleShouldContain(PLUGIN_NAME); - - // Check that the Plugin manifest tab exists - cy.get('[role="tablist"]').within(() => { - cy.contains('Plugin manifest').should('be.visible'); - }); - }); - - it('should navigate to manifest tab and display read-only code editor with JSON content', () => { - // Navigate directly to the manifest tab - cy.visit(`/k8s/cluster/console.openshift.io~v1~ConsolePlugin/${PLUGIN_NAME}/plugin-manifest`); - - // Verify we're on the manifest tab - cy.url().should('include', '/plugin-manifest'); - - // Verify the manifest tab is active (PatternFly v6 uses pf-m-current class) - cy.get('[role="tablist"]').within(() => { - cy.contains('Plugin manifest').parent().should('have.class', 'pf-m-current'); - }); - - // Wait for the page to load - detailsPage.isLoaded(); - - // Check if manifest content is displayed - cy.get('body').then(($body) => { - if ($body.find('.co-code-editor').length > 0) { - // Code editor is present - verify it contains JSON content - cy.get('.co-code-editor').should('be.visible'); - - // Verify the editor is read-only by checking PatternFly read-only class - cy.get('.pf-v6-c-code-editor').should('have.class', 'pf-m-read-only'); - - // Verify the editor contains typical plugin manifest structure using yaml-editor utilities - getEditorContent().then((content) => { - expect(content).to.contain('"name"'); - // Only check for version in local dev environment where manifest is fully available - if (isLocalDevEnvironment) { - expect(content).to.contain('"version"'); - } - }); - } else if ($body.find('[data-test="empty-box"]').length > 0) { - // Empty state is shown when no manifest is available - cy.get('[data-test="empty-box"]').should('be.visible'); - cy.log('Plugin manifest not available - empty state displayed'); - } else { - // Fallback: just verify the page loaded without errors - cy.get('[data-test="page-heading"]').should('be.visible'); - cy.log('Manifest tab loaded but no code editor or empty state found'); - } - }); - }); - - it('console plugin proxy should directly copy the plugin service proxy response status code', () => { - if (!isLocalDevEnvironment) { - let pluginStatusCode; - cy.exec(`oc -n console-demo-plugin create route passthrough --service console-demo-plugin`); - cy.exec( - `oc get route console-demo-plugin -n console-demo-plugin -o jsonpath='{.spec.host}'`, - ).then((result) => { - const consoleDemoPluginHost = result.stdout; - cy.request(`https://${consoleDemoPluginHost}/plugin-manifest.json`).then((resp) => { - pluginStatusCode = resp.status; - }); - }); - cy.request('/api/plugins/console-demo-plugin/plugin-manifest.json').then((resp) => { - expect(resp.status).to.eq(pluginStatusCode); - }); - } - }); - - it('allow disabling dynamic plugins through a query parameter', () => { - // disable non-existing plugin will make no changes - cy.visit('?disable-plugins=foo,bar'); - cy.byTestID('nav').as('dynamic_nav').should('include.text', 'Dynamic Nav'); - - // disable one plugin - cy.visit('?disable-plugins=console-demo-plugin'); - cy.get('@dynamic_nav').should('not.have.text', 'Dynamic Nav'); - - // disable all plugins - cy.visit('?disable-plugins'); - cy.get('@dynamic_nav').should('not.have.text', 'Dynamic Nav'); - cy.visit('/api-explorer'); - }); - }); -} else { - xdescribe('Skipping demo dynamic plugin tests', () => { - it('If we are running with a console-operator build, skip this test as we can not build the demo plugin', () => {}); - }); -} diff --git a/frontend/packages/integration-tests/tests/app/poll-console-updates.cy.ts b/frontend/packages/integration-tests/tests/app/poll-console-updates.cy.ts deleted file mode 100644 index a30ace746e7..00000000000 --- a/frontend/packages/integration-tests/tests/app/poll-console-updates.cy.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { checkErrors } from '../../support'; -import { refreshWebConsoleLink } from '../../views/form'; - -const CHECK_UPDATES_URL = '/api/check-updates'; -const CHECK_UPDATES_ALIAS = 'checkUpdates'; -const CHECK_MANIFEST_ALIAS = 'checkManifest'; -const CHECK_MANIFEST_ALIAS2 = 'checkManifest2'; -const PLUGINS_DEFAULT = []; -const HASH_DEFAULT = 'hash'; -const UPDATES_DEFAULT = { - consoleCommit: HASH_DEFAULT, - plugins: PLUGINS_DEFAULT, -}; -const UPDATES_NEW_COMMIT = { - consoleCommit: 'newhash', - plugins: PLUGINS_DEFAULT, -}; -const PLUGIN_NAME = 'console-demo-plugin'; -const PLUGIN_NAME2 = 'console-demo-plugin2'; -const UPDATES_NEW_PLUGIN = { - consoleCommit: HASH_DEFAULT, - plugins: [PLUGIN_NAME], -}; -const UPDATES_NEW_PLUGIN2 = { - consoleCommit: HASH_DEFAULT, - plugins: [PLUGIN_NAME, PLUGIN_NAME2], -}; -const PLUGIN_MANIFEST_URL = `/api/plugins/${PLUGIN_NAME}/plugin-manifest.json`; -const PLUGIN_MANIFEST_URL2 = `/api/plugins/${PLUGIN_NAME2}/plugin-manifest.json`; -const PLUGIN_MANIFEST_DEFAULT = { - name: PLUGIN_NAME, - version: '0.0.0', -}; -const PLUGIN_MANIFEST_DEFAULT2 = { - name: PLUGIN_NAME2, - version: '0.0.0', -}; -const PLUGIN_MANIFEST_NEW_VERSION = { - name: PLUGIN_NAME, - version: '1.0.0', -}; -const WAIT_OPTIONS = { timeout: 300000 }; - -const loadApp = () => { - cy.visit('/'); -}; -const checkConsoleUpdateToast = () => { - cy.byTestID(refreshWebConsoleLink).should('exist').click(); - cy.get(refreshWebConsoleLink).should('not.exist'); - cy.byTestID('loading-indicator').should('not.exist'); -}; - -// TODO Fix once we figure out how to handle the case where console reloads after rollout -xdescribe('PollConsoleUpdates Test', () => { - before(() => { - cy.login(); - }); - - afterEach(() => { - checkErrors(); - }); - - it('triggers the console update toast when consoleCommit changes', () => { - loadApp(); - cy.intercept(CHECK_UPDATES_URL, UPDATES_DEFAULT).as(CHECK_UPDATES_ALIAS); - cy.wait(`@${CHECK_UPDATES_ALIAS}`, WAIT_OPTIONS); - cy.intercept(CHECK_UPDATES_URL, UPDATES_NEW_COMMIT).as(CHECK_UPDATES_ALIAS); - cy.wait(`@${CHECK_UPDATES_ALIAS}`, WAIT_OPTIONS); - checkConsoleUpdateToast(); - }); - - it('triggers the console update toast when a plugin is added', () => { - loadApp(); - cy.intercept(CHECK_UPDATES_URL, UPDATES_DEFAULT).as(CHECK_UPDATES_ALIAS); - cy.wait(`@${CHECK_UPDATES_ALIAS}`, WAIT_OPTIONS); - cy.intercept(CHECK_UPDATES_URL, UPDATES_NEW_PLUGIN).as(CHECK_UPDATES_ALIAS); - cy.intercept(PLUGIN_MANIFEST_URL, { forceNetworkError: true }).as(CHECK_MANIFEST_ALIAS); - cy.wait(`@${CHECK_UPDATES_ALIAS}`, WAIT_OPTIONS); - cy.wait(`@${CHECK_MANIFEST_ALIAS}`, WAIT_OPTIONS).should('have.property', 'error'); - cy.get(refreshWebConsoleLink).should('not.exist'); - cy.intercept(PLUGIN_MANIFEST_URL, PLUGIN_MANIFEST_DEFAULT).as(CHECK_MANIFEST_ALIAS); - cy.wait(`@${CHECK_MANIFEST_ALIAS}`, WAIT_OPTIONS); - checkConsoleUpdateToast(); - }); - - it('triggers the console update toast when a plugin is added and a different plugin endpoint is erroring', () => { - loadApp(); - cy.intercept(CHECK_UPDATES_URL, UPDATES_NEW_PLUGIN).as(CHECK_UPDATES_ALIAS); - cy.intercept(PLUGIN_MANIFEST_URL, { forceNetworkError: true }).as(CHECK_MANIFEST_ALIAS); - cy.wait(`@${CHECK_UPDATES_ALIAS}`, WAIT_OPTIONS); - cy.wait(`@${CHECK_MANIFEST_ALIAS}`, WAIT_OPTIONS).should('have.property', 'error'); - cy.byTestID('loading-indicator').should('not.exist'); - cy.get(refreshWebConsoleLink).should('not.exist'); - cy.intercept(CHECK_UPDATES_URL, UPDATES_NEW_PLUGIN2).as(CHECK_UPDATES_ALIAS); - cy.wait(`@${CHECK_UPDATES_ALIAS}`, WAIT_OPTIONS); - cy.intercept(PLUGIN_MANIFEST_URL2, { forceNetworkError: true }).as(CHECK_MANIFEST_ALIAS2); - cy.wait(`@${CHECK_MANIFEST_ALIAS2}`, WAIT_OPTIONS); - cy.wait(`@${CHECK_MANIFEST_ALIAS2}`, WAIT_OPTIONS).should('have.property', 'error'); - cy.byTestID('loading-indicator').should('not.exist'); - cy.get(refreshWebConsoleLink).should('not.exist'); - cy.intercept(PLUGIN_MANIFEST_URL2, PLUGIN_MANIFEST_DEFAULT2).as(CHECK_MANIFEST_ALIAS2); - cy.wait(`@${CHECK_MANIFEST_ALIAS2}`, WAIT_OPTIONS); - checkConsoleUpdateToast(); - }); - - it('triggers the console update toast when a plugin is removed', () => { - loadApp(); - cy.intercept(CHECK_UPDATES_URL, UPDATES_NEW_PLUGIN).as(CHECK_UPDATES_ALIAS); - cy.intercept(PLUGIN_MANIFEST_URL, PLUGIN_MANIFEST_DEFAULT).as(CHECK_MANIFEST_ALIAS); - cy.wait([`@${CHECK_UPDATES_ALIAS}`, `@${CHECK_MANIFEST_ALIAS}`], WAIT_OPTIONS); - cy.intercept(CHECK_UPDATES_URL, UPDATES_DEFAULT).as(CHECK_UPDATES_ALIAS); - cy.wait(`@${CHECK_UPDATES_ALIAS}`, WAIT_OPTIONS); - checkConsoleUpdateToast(); - }); - - it('triggers the console update toast when a plugin version changes', () => { - loadApp(); - cy.intercept(CHECK_UPDATES_URL, UPDATES_NEW_PLUGIN).as(CHECK_UPDATES_ALIAS); - cy.intercept(PLUGIN_MANIFEST_URL, PLUGIN_MANIFEST_DEFAULT).as(CHECK_MANIFEST_ALIAS); - cy.wait([`@${CHECK_UPDATES_ALIAS}`, `@${CHECK_MANIFEST_ALIAS}`], WAIT_OPTIONS); - cy.intercept(PLUGIN_MANIFEST_URL, PLUGIN_MANIFEST_NEW_VERSION).as(CHECK_MANIFEST_ALIAS); - cy.wait(`@${CHECK_MANIFEST_ALIAS}`, WAIT_OPTIONS); - checkConsoleUpdateToast(); - }); -}); diff --git a/frontend/packages/integration-tests/tests/app/yaml-editor.cy.ts b/frontend/packages/integration-tests/tests/app/yaml-editor.cy.ts deleted file mode 100644 index 4dcbe9d6c59..00000000000 --- a/frontend/packages/integration-tests/tests/app/yaml-editor.cy.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { checkErrors } from '../../support'; -import * as common from '../../views/common'; -import { detailsPage } from '../../views/details-page'; -import { listPage } from '../../views/list-page'; -import * as yamlEditor from '../../views/yaml-editor'; - -const YAML_SAMPLE = `apiVersion: v1 -kind: ConfigMap -metadata: - name: test-config - namespace: default -data: - key: value`; - -describe('YAML Editor Settings', () => { - before(() => { - cy.login(); - cy.visit('/k8s/ns/default/import'); - }); - - beforeEach(() => { - // Wait for YAML editor to load - yamlEditor.isImportLoaded(); - }); - - afterEach(() => { - checkErrors(); - }); - - after(() => { - cy.visit('/'); - }); - - describe('Settings Modal', () => { - it('should open the editor settings modal', () => { - yamlEditor.openEditorSettingsModal(); - yamlEditor.verifyEditorSettingsModalIsOpen(); - yamlEditor.closeEditorSettingsModal(); - yamlEditor.verifyEditorSettingsModalIsClosed(); - }); - }); - - describe('Theme Setting', () => { - beforeEach(() => { - yamlEditor.openEditorSettingsModal(); - }); - - afterEach(() => { - yamlEditor.closeEditorSettingsModal(); - }); - - it('should toggle theme to Dark mode', () => { - yamlEditor.selectTheme('Dark'); - yamlEditor.verifyEditorTheme('vs-dark'); - }); - - it('should toggle theme to Light mode', () => { - yamlEditor.selectTheme('Light'); - yamlEditor.verifyEditorTheme('vs'); - }); - - it('should revert to default theme setting', () => { - yamlEditor.selectTheme('Use theme setting'); - yamlEditor.verifyEditorTheme(null); - }); - }); - - describe('Font Size Setting', () => { - beforeEach(() => { - yamlEditor.setEditorContent(YAML_SAMPLE); - yamlEditor.openEditorSettingsModal(); - }); - - afterEach(() => { - yamlEditor.closeEditorSettingsModal(); - }); - - it('should increase font size', () => { - yamlEditor - .getFontSizeInput() - .invoke('val') - .then((initialSize) => { - const currentSize = Number(initialSize); - // Click twice to increase by 2 - yamlEditor.getFontSizeIncreaseButton().click().click(); - yamlEditor.getFontSizeInput().should('have.value', (currentSize + 2).toString()); - yamlEditor.verifyFontSizeInEditor(currentSize + 2); - }); - }); - - it('should decrease font size', () => { - yamlEditor - .getFontSizeInput() - .invoke('val') - .then((initialSize) => { - const currentSize = Number(initialSize); - yamlEditor.getFontSizeDecreaseButton().click(); - yamlEditor.getFontSizeInput().should('have.value', (currentSize - 1).toString()); - yamlEditor.verifyFontSizeInEditor(currentSize - 1); - }); - }); - - it('should not decrease font size below minimum (5px)', () => { - yamlEditor.setFontSize(5); - yamlEditor.getFontSizeDecreaseButton().should('have.attr', 'disabled'); - }); - - it('should allow manual font size input', () => { - yamlEditor.setFontSize(18); - yamlEditor.getFontSizeInput().should('have.value', '18'); - yamlEditor.verifyFontSizeInEditor(18); - }); - }); - - describe('Settings Persistence', () => { - it('should persist settings after modal close and reopen', () => { - yamlEditor.openEditorSettingsModal(); - - yamlEditor.selectTheme('Dark'); - yamlEditor.setFontSize(16); - - yamlEditor.closeEditorSettingsModal(); - yamlEditor.openEditorSettingsModal(); - - // Verify settings persisted - yamlEditor.verifyEditorTheme('vs-dark'); - yamlEditor.getFontSizeInput().should('have.value', '16'); - yamlEditor.closeEditorSettingsModal(); - }); - - it('should persist user settings across pages', () => { - // Set custom settings on import YAML page - yamlEditor.openEditorSettingsModal(); - - yamlEditor.selectTheme('Light'); - yamlEditor.setFontSize(20); - - yamlEditor.closeEditorSettingsModal(); - - // Verify settings are applied - yamlEditor.verifyEditorTheme('vs'); - yamlEditor.verifyFontSizeInEditor(20); - - // Navigate to a pod YAML page - cy.visit('/k8s/ns/openshift-console/pods'); - listPage.dvRows.shouldBeLoaded(); - listPage.dvRows.clickFirstLinkInFirstRow(); - detailsPage.selectTab('YAML'); - cy.get('.monaco-editor').should('be.visible'); - - // Verify settings persisted across page navigation - yamlEditor.verifyEditorTheme('vs'); - yamlEditor.verifyFontSizeInEditor(20); - }); - }); -}); - -describe('Yaml editor sidebar', () => { - before(() => { - cy.login(); - }); - it('Show possible enum values in yaml sidebar', () => { - cy.clickNavLink(['Workloads', 'Deployments']); - common.projectDropdown.selectProject('openshift-console'); - common.projectDropdown.shouldContain('openshift-console'); - cy.get('#content-scrollable', { timeout: 30000 }).should('exist'); - listPage.dvRows.shouldBeLoaded(); - listPage.dvRows.clickRowByName('downloads'); - detailsPage.isLoaded(); - detailsPage.selectTab('YAML'); - cy.get('button[aria-label="Show sidebar"]', { timeout: 30000 }).should('exist'); - yamlEditor.showYAMLSidebar(); - cy.contains('button', 'Schema').should('exist'); - yamlEditor.clickFieldDetailsButton('spec'); - yamlEditor.clickFieldDetailsButton('strategy'); - cy.contains('p', 'Allowed values:').should('exist'); - cy.contains('p', 'Recreate, RollingUpdate').should('exist'); - }); -}); diff --git a/frontend/public/components/modals/delete-modal.tsx b/frontend/public/components/modals/delete-modal.tsx index 7f44d2f704b..eb4105e05d9 100644 --- a/frontend/public/components/modals/delete-modal.tsx +++ b/frontend/public/components/modals/delete-modal.tsx @@ -102,6 +102,7 @@ const DeleteModal = (props: DeleteModalProps) => { })} } + data-test="modal-title" data-test-id="modal-title" />