diff --git a/app/e2e-tests/tests/clusterRename.spec.ts b/app/e2e-tests/tests/clusterRename.spec.ts new file mode 100644 index 00000000000..f79f7e925f8 --- /dev/null +++ b/app/e2e-tests/tests/clusterRename.spec.ts @@ -0,0 +1,102 @@ +import { expect, test } from '@playwright/test'; +import path from 'path'; +import { _electron, Page } from 'playwright'; +import { HeadlampPage } from './headlampPage'; + +// Electron setup +const electronExecutable = process.platform === 'win32' ? 'electron.cmd' : 'electron'; +const electronPath = path.resolve(__dirname, `../../node_modules/.bin/${electronExecutable}`); +const appPath = path.resolve(__dirname, '../../'); +let electronApp; +let electronPage: Page; + +// Test configuration +const TEST_CONFIG = { + originalName: 'minikube', + newName: 'test-cluster', + invalidName: 'Invalid Cluster!', +}; + +// Helper functions +async function navigateToSettings(page: Page) { + await page.waitForLoadState('load'); + await page.getByRole('button', { name: 'Settings' }).click(); + await page.waitForLoadState('load'); +} + +async function verifyClusterName(page: Page, expectedName: string) { + await navigateToSettings(page); + await expect(page.locator('h2')).toContainText(`Cluster Settings (${expectedName})`); +} + +async function renameCluster( + page: Page, + fromName: string, + toName: string, + confirm: boolean = true +) { + await page.fill(`input[placeholder="${fromName}"]`, toName); + await page.getByRole('button', { name: 'Apply' }).click(); + await page.getByRole('button', { name: confirm ? 'Yes' : 'No' }).click(); +} + +// Setup +test.beforeAll(async () => { + electronApp = await _electron.launch({ + cwd: appPath, + executablePath: electronPath, + args: ['.'], + env: { + ...process.env, + NODE_ENV: 'development', + ELECTRON_DEV: 'true', + }, + }); + + electronPage = await electronApp.firstWindow(); +}); + +test.beforeEach(async ({ page }) => { + page.close(); +}); + +// Tests +test.describe('Cluster rename functionality', () => { + test.beforeEach(() => { + test.skip(process.env.PLAYWRIGHT_TEST_MODE !== 'app', 'These tests only run in app mode'); + }); + + test('should rename cluster and verify changes', async ({ page: browserPage }) => { + const page = process.env.PLAYWRIGHT_TEST_MODE === 'app' ? electronPage : browserPage; + const headlampPage = new HeadlampPage(page); + await headlampPage.authenticate(); + + await navigateToSettings(page); + await expect(page.locator('h2')).toContainText('Cluster Settings'); + + // Test invalid inputs + await page.fill('input[placeholder="minikube"]', TEST_CONFIG.invalidName); + await expect(page.getByRole('button', { name: 'Apply' })).toBeDisabled(); + + await page.fill('input[placeholder="minikube"]', ''); + await expect(page.getByRole('button', { name: 'Apply' })).toBeDisabled(); + + // Test successful rename + await renameCluster(page, TEST_CONFIG.originalName, TEST_CONFIG.newName); + await verifyClusterName(page, TEST_CONFIG.newName); + + // Rename back to original + await renameCluster(page, TEST_CONFIG.newName, TEST_CONFIG.originalName); + await verifyClusterName(page, TEST_CONFIG.originalName); + }); + + test('should cancel rename operation', async ({ page: browserPage }) => { + const page = process.env.PLAYWRIGHT_TEST_MODE === 'app' ? electronPage : browserPage; + const headlampPage = new HeadlampPage(page); + await headlampPage.authenticate(); + + await navigateToSettings(page); + await renameCluster(page, TEST_CONFIG.originalName, TEST_CONFIG.newName, false); + await expect(page.getByText(`Cluster Settings (${TEST_CONFIG.originalName})`)).toBeVisible(); + }); +}); diff --git a/frontend/src/components/App/Settings/ClusterNameEditor.stories.tsx b/frontend/src/components/App/Settings/ClusterNameEditor.stories.tsx new file mode 100644 index 00000000000..c7bdc0ad04d --- /dev/null +++ b/frontend/src/components/App/Settings/ClusterNameEditor.stories.tsx @@ -0,0 +1,39 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { ClusterNameEditor } from './ClusterNameEditor'; + +const meta: Meta = { + title: 'Settings/ClusterNameEditor', + component: ClusterNameEditor, + parameters: { + layout: 'centered', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + cluster: 'my-cluster', + newClusterName: '', + isValidCurrentName: true, + source: 'dynamic_cluster', + onClusterNameChange: () => {}, + onUpdateClusterName: () => {}, + }, +}; + +export const WithInvalidName: Story = { + args: { + ...Default.args, + newClusterName: 'Invalid Cluster Name', + isValidCurrentName: false, + }, +}; + +export const WithNewName: Story = { + args: { + ...Default.args, + newClusterName: 'new-cluster-name', + }, +}; diff --git a/frontend/src/components/App/Settings/ClusterNameEditor.tsx b/frontend/src/components/App/Settings/ClusterNameEditor.tsx new file mode 100644 index 00000000000..f2c3fe01a01 --- /dev/null +++ b/frontend/src/components/App/Settings/ClusterNameEditor.tsx @@ -0,0 +1,88 @@ +import { Box, TextField } from '@mui/material'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ConfirmButton, NameValueTable } from '../../common'; + +interface ClusterNameEditorProps { + cluster: string; + newClusterName: string; + isValidCurrentName: boolean; + source: string; + onClusterNameChange: (name: string) => void; + onUpdateClusterName: (source: string) => void; +} + +export function ClusterNameEditor({ + cluster, + newClusterName, + isValidCurrentName, + source, + onClusterNameChange, + onUpdateClusterName, +}: ClusterNameEditorProps) { + const { t } = useTranslation(['translation']); + + const invalidClusterNameMessage = t( + "translation|Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character." + ); + + const handleChange = (event: React.ChangeEvent) => { + const value = event.target.value.replace(' ', ''); + onClusterNameChange(value); + }; + + const handleKeyPress = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && isValidCurrentName) { + onUpdateClusterName(source); + } + }; + + return ( + + { + if (isValidCurrentName) { + onUpdateClusterName(source); + } + }} + confirmTitle={t('translation|Change name')} + confirmDescription={t( + 'translation|Are you sure you want to change the name for "{{ clusterName }}"?', + { clusterName: cluster } + )} + disabled={!newClusterName || !isValidCurrentName} + > + {t('translation|Apply')} + + + ), + onKeyPress: handleKeyPress, + autoComplete: 'off', + sx: { maxWidth: 250 }, + }} + /> + ), + }, + ]} + /> + ); +} diff --git a/frontend/src/components/App/Settings/SettingsCluster.tsx b/frontend/src/components/App/Settings/SettingsCluster.tsx index 67eaafa6296..4095b2995b5 100644 --- a/frontend/src/components/App/Settings/SettingsCluster.tsx +++ b/frontend/src/components/App/Settings/SettingsCluster.tsx @@ -22,6 +22,7 @@ import { findKubeconfigByClusterName, updateStatelessClusterKubeconfig } from '. import { Link, Loader, NameValueTable, SectionBox } from '../../common'; import ConfirmButton from '../../common/ConfirmButton'; import Empty from '../../common/EmptyContent'; +import { ClusterNameEditor } from './ClusterNameEditor'; function isValidNamespaceFormat(namespace: string) { // We allow empty strings just because that's the default value in our case. @@ -272,10 +273,6 @@ export default function SettingsCluster() { "translation|Namespaces must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character." ); - const invalidClusterNameMessage = t( - "translation|Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character." - ); - // If we don't have yet a cluster name from the URL, we are still loading. if (!clusterFromURLRef.current) { return ; @@ -333,59 +330,13 @@ export default function SettingsCluster() { {helpers.isElectron() && ( - { - let value = event.target.value; - value = value.replace(' ', ''); - setNewClusterName(value); - }} - value={newClusterName} - placeholder={cluster} - error={!isValidCurrentName} - helperText={ - isValidCurrentName - ? t( - 'translation|The current name of cluster. You can define custom modified name.' - ) - : invalidClusterNameMessage - } - InputProps={{ - endAdornment: ( - - { - if (isValidCurrentName) { - handleUpdateClusterName(source); - } - }} - confirmTitle={t('translation|Change name')} - confirmDescription={t( - 'translation|Are you sure you want to change the name for "{{ clusterName }}"?', - { clusterName: cluster } - )} - disabled={!newClusterName || !isValidCurrentName} - > - {t('translation|Apply')} - - - ), - onKeyPress: event => { - if (event.key === 'Enter' && isValidCurrentName) { - handleUpdateClusterName(source); - } - }, - autoComplete: 'off', - sx: { maxWidth: 250 }, - }} - /> - ), - }, - ]} + )} +
+
+
+ Name +
+
+
+
+ +
+ +
+
+
+

+ The current name of cluster. You can define custom modified name. +

+
+
+
+
+ \ No newline at end of file diff --git a/frontend/src/components/App/Settings/__snapshots__/ClusterNameEditor.WithInvalidName.stories.storyshot b/frontend/src/components/App/Settings/__snapshots__/ClusterNameEditor.WithInvalidName.stories.storyshot new file mode 100644 index 00000000000..d53d21da79e --- /dev/null +++ b/frontend/src/components/App/Settings/__snapshots__/ClusterNameEditor.WithInvalidName.stories.storyshot @@ -0,0 +1,54 @@ + +
+
+
+ Name +
+
+
+
+ +
+ +
+
+
+

+ Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character. +

+
+
+
+
+ \ No newline at end of file diff --git a/frontend/src/components/App/Settings/__snapshots__/ClusterNameEditor.WithNewName.stories.storyshot b/frontend/src/components/App/Settings/__snapshots__/ClusterNameEditor.WithNewName.stories.storyshot new file mode 100644 index 00000000000..7b01e3e6e3b --- /dev/null +++ b/frontend/src/components/App/Settings/__snapshots__/ClusterNameEditor.WithNewName.stories.storyshot @@ -0,0 +1,56 @@ + +
+
+
+ Name +
+
+
+
+ +
+ +
+
+
+

+ The current name of cluster. You can define custom modified name. +

+
+
+
+
+ \ No newline at end of file diff --git a/frontend/src/stateless/index.test.ts b/frontend/src/stateless/index.test.ts new file mode 100644 index 00000000000..bc33c34b3e9 --- /dev/null +++ b/frontend/src/stateless/index.test.ts @@ -0,0 +1,164 @@ +import * as jsyaml from 'js-yaml'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { updateStatelessClusterKubeconfig } from './index'; + +// Helper function to create mock IDB request +function createMockRequest(): MockRequest { + return { + onsuccess: null, + onerror: null, + result: null, + error: null, + source: {} as IDBObjectStore, + transaction: {} as IDBTransaction, + readyState: 'pending' as IDBRequestReadyState, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => true, + }; +} + +// Helper function to create mock cursor +function createMockCursor(kubeconfig: string) { + return { + value: { kubeconfig, id: 1 }, + key: 1, + continue: () => {}, + update: () => {}, + delete: () => {}, + source: {} as IDBObjectStore, + direction: 'next', + primaryKey: 1, + }; +} + +interface MockRequest extends IDBRequest { + onsuccess: ((this: IDBRequest, ev: Event) => any) | null; + onerror: ((this: IDBRequest, ev: Event) => any) | null; + result: any; + error: DOMException | null; + source: IDBObjectStore; + transaction: IDBTransaction; + readyState: IDBRequestReadyState; +} + +describe('updateStatelessClusterKubeconfig', () => { + const mockStore = { + put: vi.fn().mockImplementation(() => { + const request = createMockRequest(); + setTimeout(() => { + request.onsuccess?.(new Event('success')); + }, 0); + return request; + }), + openCursor: vi.fn().mockImplementation(() => { + const request = createMockRequest(); + return request; + }), + }; + + const mockDB = { + transaction: vi.fn().mockReturnValue({ + objectStore: vi.fn().mockReturnValue(mockStore), + }), + name: 'mockDB', + version: 1, + objectStoreNames: ['kubeconfigs'] as unknown as DOMStringList, + onabort: null, + onclose: null, + onerror: null, + onversionchange: null, + close: vi.fn(), + createObjectStore: vi.fn(), + deleteObjectStore: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn().mockReturnValue(true), + } as IDBDatabase; + + beforeAll(() => { + const mockIDBRequest = { + result: mockDB, + onupgradeneeded: null, + onsuccess: null, + onerror: null, + } as IDBOpenDBRequest; + + vi.stubGlobal('indexedDB', { + open: vi.fn().mockImplementation(() => { + setTimeout(() => { + mockIDBRequest.onsuccess?.({ target: { result: mockDB } } as any); + }, 0); + return mockIDBRequest; + }), + }); + }); + + afterAll(() => { + vi.unstubAllGlobals(); + }); + + function setupMockCursor(base64Kubeconfig: string) { + mockStore.openCursor.mockImplementation(() => { + const request = createMockRequest(); + setTimeout(() => { + if (request.onsuccess) { + const cursor = createMockCursor(base64Kubeconfig); + const event = { target: { result: cursor } }; + request.onsuccess(event as any); + } + }, 0); + return request; + }); + } + + function createTestKubeconfig(contextName: string, extensions?: any[]) { + return { + contexts: [ + { + name: contextName, + context: { + cluster: contextName, + user: 'test-user', + ...(extensions && { extensions }), + }, + }, + ], + }; + } + + it('should update existing kubeconfig with new custom name', async () => { + const mockKubeconfig = createTestKubeconfig('test-cluster'); + const base64Kubeconfig = btoa(jsyaml.dump(mockKubeconfig)); + setupMockCursor(base64Kubeconfig); + + await updateStatelessClusterKubeconfig(base64Kubeconfig, 'new-name', 'test-cluster'); + expect(mockStore.put).toHaveBeenCalled(); + }); + + it('should reject if no matching context is found', async () => { + const mockKubeconfig = createTestKubeconfig('different-cluster'); + const base64Kubeconfig = btoa(jsyaml.dump(mockKubeconfig)); + + await expect( + updateStatelessClusterKubeconfig(base64Kubeconfig, 'new-name', 'test-cluster') + ).rejects.toEqual('No context found matching the cluster name'); + }); + + it('should update existing headlamp_info extension', async () => { + const mockKubeconfig = createTestKubeconfig('test-cluster', [ + { + name: 'headlamp_info', + extension: { + customName: 'old-name', + }, + }, + ]); + + const base64Kubeconfig = btoa(jsyaml.dump(mockKubeconfig)); + setupMockCursor(base64Kubeconfig); + + await updateStatelessClusterKubeconfig(base64Kubeconfig, 'new-name', 'test-cluster'); + expect(mockStore.put).toHaveBeenCalled(); + }); +});