diff --git a/frontend/src/__mocks__/mockPVCK8sResource.ts b/frontend/src/__mocks__/mockPVCK8sResource.ts index 972d586f91..b1d4c1f092 100644 --- a/frontend/src/__mocks__/mockPVCK8sResource.ts +++ b/frontend/src/__mocks__/mockPVCK8sResource.ts @@ -5,26 +5,30 @@ type MockResourceConfigType = { name?: string; namespace?: string; storage?: string; + displayName?: string; + uid?: string; }; export const mockPVCK8sResource = ({ name = 'test-storage', namespace = 'test-project', storage = '5Gi', + displayName = 'Test Storage', + uid = genUID('pvc'), }: MockResourceConfigType): PersistentVolumeClaimKind => ({ kind: 'PersistentVolumeClaim', apiVersion: 'v1', metadata: { annotations: { 'openshift.io/description': '', - 'openshift.io/display-name': 'Test Storage', + 'openshift.io/display-name': displayName, }, name, namespace, labels: { [KnownLabels.DASHBOARD_RESOURCE]: 'true', }, - uid: genUID('pvc'), + uid, }, spec: { accessModes: ['ReadWriteOnce'], diff --git a/frontend/src/__tests__/cypress/cypress/e2e/projects/ClusterStorage.cy.ts b/frontend/src/__tests__/cypress/cypress/e2e/projects/ClusterStorage.cy.ts new file mode 100644 index 0000000000..226dc4b353 --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/e2e/projects/ClusterStorage.cy.ts @@ -0,0 +1,216 @@ +import { + mockDashboardConfig, + mockDscStatus, + mockK8sResourceList, + mockNotebookK8sResource, + mockProjectK8sResource, + mockStatus, +} from '~/__mocks__'; +import { mockClusterSettings } from '~/__mocks__/mockClusterSettings'; +import { mockPVCK8sResource } from '~/__mocks__/mockPVCK8sResource'; +import { mockPodK8sResource } from '~/__mocks__/mockPodK8sResource'; +import { + clusterStorage, + addClusterStorageModal, + updateClusterStorageModal, +} from '~/__tests__/cypress/cypress/pages/clusterStorage'; +import { deleteModal } from '~/__tests__/cypress/cypress/pages/components/DeleteModal'; +import { be } from '~/__tests__/cypress/cypress/utils/should'; + +type HandlersProps = { + isEmpty?: boolean; +}; + +const initInterceptors = ({ isEmpty = false }: HandlersProps) => { + cy.intercept('/api/status', mockStatus()); + cy.intercept('/api/config', mockDashboardConfig({})); + cy.intercept('/api/cluster-settings', mockClusterSettings({})); + cy.intercept('/api/dsc/status', mockDscStatus({})); + cy.intercept( + { pathname: '/api/k8s/api/v1/namespaces/test-project/pods' }, + mockK8sResourceList([mockPodK8sResource({})]), + ); + cy.intercept( + { pathname: '/api/k8s/apis/project.openshift.io/v1/projects' }, + mockK8sResourceList([mockProjectK8sResource({})]), + ); + cy.intercept( + { pathname: '/api/k8s/apis/project.openshift.io/v1/projects/test-project' }, + mockProjectK8sResource({}), + ); + cy.intercept({ pathname: '/api/prometheus/pvc' }, mockPVCK8sResource({})); + cy.intercept( + { + method: 'GET', + pathname: + '/api/k8s/apis/opendatahub.io/v1alpha/namespaces/opendatahub/odhdashboardconfigs/odh-dashboard-config', + }, + mockDashboardConfig({}), + ); + cy.intercept( + { pathname: '/api/k8s/api/v1/namespaces/test-project/persistentvolumeclaims' }, + mockK8sResourceList( + isEmpty + ? [] + : [ + mockPVCK8sResource({ uid: 'test-id' }), + mockPVCK8sResource({ displayName: 'Another Cluster Storage' }), + ], + ), + ); + cy.intercept( + { + pathname: '/api/k8s/apis/kubeflow.org/v1/namespaces/test-project/notebooks', + }, + mockK8sResourceList([mockNotebookK8sResource({})]), + ); +}; + +describe('ClusterStorage', () => { + it('Empty state', () => { + initInterceptors({ isEmpty: true }); + clusterStorage.visit('test-project'); + clusterStorage.findEmptyState().should('exist'); + clusterStorage.findCreateButton().should('be.enabled'); + clusterStorage.findCreateButton().click(); + }); + + it('Add cluster storage', () => { + initInterceptors({ isEmpty: true }); + clusterStorage.visit('test-project'); + clusterStorage.findCreateButton().click(); + addClusterStorageModal.findNameInput().fill('test-storage'); + addClusterStorageModal.findSubmitButton().should('be.enabled'); + addClusterStorageModal.findDescriptionInput().fill('description'); + addClusterStorageModal.findPVSizeMinusButton().click(); + addClusterStorageModal.findPVSizeInput().should('have.value', '19'); + addClusterStorageModal.findPVSizePlusButton().click(); + addClusterStorageModal.findPVSizeInput().should('have.value', '20'); + addClusterStorageModal.selectPVSize('Mi'); + + //connect workbench + addClusterStorageModal + .findWorkbenchConnectionSelect() + .findSelectOption('Test Notebook') + .click(); + addClusterStorageModal.findMountField().fill('data'); + addClusterStorageModal.findWorkbenchRestartAlert().should('exist'); + + cy.intercept( + { + method: 'PATCH', + pathname: '/api/k8s/apis/kubeflow.org/v1/namespaces/test-project/notebooks/test-notebook', + }, + mockNotebookK8sResource({}), + ).as('addClusterStorage'); + + cy.intercept( + { pathname: '/api/k8s/api/v1/namespaces/test-project/persistentvolumeclaims' }, + mockK8sResourceList([mockPVCK8sResource({})]), + ); + + addClusterStorageModal.findSubmitButton().click(); + cy.wait('@addClusterStorage').then((interception) => { + expect(interception.request.url).to.include('?dryRun=All'); + expect(interception.request.body).to.eql([ + { op: 'add', path: '/spec/template/spec/volumes/-', value: { persistentVolumeClaim: {} } }, + { + op: 'add', + path: '/spec/template/spec/containers/0/volumeMounts/-', + value: { mountPath: '/opt/app-root/src/data' }, + }, + ]); + }); + + cy.wait('@addClusterStorage').then((interception) => { + expect(interception.request.url).not.to.include('?dryRun=All'); + }); + + cy.get('@addClusterStorage.all').then((interceptions) => { + expect(interceptions).to.have.length(2); + }); + }); + + it('list accelerator profiles and Table sorting', () => { + initInterceptors({}); + clusterStorage.visit('test-project'); + const clusterStorageRow = clusterStorage.getClusterStorageRow('Test Storage'); + clusterStorageRow.shouldHaveStorageTypeValue('Persistent storage'); + clusterStorageRow.findConnectedWorkbenches().should('have.text', 'No connections'); + clusterStorageRow.toggleExpandableContent(); + clusterStorageRow.shouldHaveStorageSize('Max 5Gi'); + + //sort by Name + clusterStorage.findClusterStorageTableHeaderButton('Name').click(); + clusterStorage.findClusterStorageTableHeaderButton('Name').should(be.sortAscending); + clusterStorage.findClusterStorageTableHeaderButton('Name').click(); + clusterStorage.findClusterStorageTableHeaderButton('Name').should(be.sortDescending); + }); + + it('Edit cluster storage', () => { + initInterceptors({}); + clusterStorage.visit('test-project'); + const clusterStorageRow = clusterStorage.getClusterStorageRow('Test Storage'); + clusterStorageRow.findKebabAction('Edit storage').click(); + updateClusterStorageModal.findNameInput().should('have.value', 'Test Storage'); + updateClusterStorageModal.findPVSizeInput().should('have.value', '5'); + updateClusterStorageModal.shouldHavePVSizeSelectValue('Gi'); + updateClusterStorageModal.findPersistentStorageWarning().should('exist'); + updateClusterStorageModal.findSubmitButton().should('be.enabled'); + updateClusterStorageModal.findNameInput().fill('test-updated'); + + cy.intercept( + { + method: 'PUT', + pathname: '/api/k8s/api/v1/namespaces/test-project/persistentvolumeclaims/test-storage', + }, + mockPVCK8sResource({}), + ).as('editClusterStorage'); + + updateClusterStorageModal.findSubmitButton().click(); + cy.wait('@editClusterStorage').then((interception) => { + expect(interception.request.url).to.include('?dryRun=All'); + expect(interception.request.body).to.containSubset({ + metadata: { + annotations: { + 'openshift.io/description': '', + 'openshift.io/display-name': 'test-updated', + }, + name: 'test-storage', + namespace: 'test-project', + }, + spec: { + resources: { requests: { storage: '5Gi' } }, + }, + status: { phase: 'Pending', accessModes: ['ReadWriteOnce'], capacity: { storage: '5Gi' } }, + }); + }); + + cy.wait('@editClusterStorage').then((interception) => { + expect(interception.request.url).not.to.include('?dryRun=All'); + }); + + cy.get('@editClusterStorage.all').then((interceptions) => { + expect(interceptions).to.have.length(2); + }); + }); + + it('Delete cluster storage', () => { + initInterceptors({}); + clusterStorage.visit('test-project'); + const clusterStorageRow = clusterStorage.getClusterStorageRow('Test Storage'); + clusterStorageRow.findKebabAction('Delete storage').click(); + deleteModal.findInput().fill('Test Storage'); + + cy.intercept( + { + method: 'DELETE', + pathname: '/api/k8s/api/v1/namespaces/test-project/persistentvolumeclaims/test-storage', + }, + { kind: 'Status', apiVersion: 'v1', metadata: {}, status: 'Success' }, + ).as('deleteClusterStorage'); + + deleteModal.findSubmitButton().click(); + cy.wait('@deleteClusterStorage'); + }); +}); diff --git a/frontend/src/__tests__/cypress/cypress/pages/clusterStorage.ts b/frontend/src/__tests__/cypress/cypress/pages/clusterStorage.ts new file mode 100644 index 0000000000..ca6e38055c --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/pages/clusterStorage.ts @@ -0,0 +1,126 @@ +import { Modal } from './components/Modal'; +import { TableRow } from './components/table'; + +class ClusterStorageRow extends TableRow { + shouldHaveStorageTypeValue(name: string) { + this.find().find(`[data-label=Type]`).contains(name).should('exist'); + return this; + } + + findConnectedWorkbenches() { + return this.find().find('[data-label="Connected workbenches"]'); + } + + toggleExpandableContent() { + this.find().findByRole('button', { name: 'Details' }).click(); + } + + shouldHaveStorageSize(name: string) { + this.find().siblings().find('[data-label=Size]').contains(name).should('exist'); + return this; + } +} + +class ClusterStorageModal extends Modal { + constructor(private edit = false) { + super(edit ? 'Update cluster storage' : 'Add cluster storage'); + } + + findWorkbenchConnectionSelect() { + return this.find() + .findByTestId('connect-existing-workbench-group') + .findByRole('button', { name: 'Options menu' }); + } + + findMountField() { + return this.find().findByTestId('mount-path-folder-value'); + } + + findWorkbenchRestartAlert() { + return this.find().findByTestId('notebook-restart-alert'); + } + + findNameInput() { + return this.find().findByTestId('create-new-storage-name'); + } + + findDescriptionInput() { + return this.find().findByTestId('create-new-storage-description'); + } + + findSubmitButton() { + return this.find().findByTestId('modal-submit-button'); + } + + private findPVSizeSelectButton() { + return cy.findByTestId('value-unit-select'); + } + + selectPVSize(name: string) { + this.findPVSizeSelectButton().click(); + cy.findByRole('menuitem', { name }).click(); + } + + shouldHavePVSizeSelectValue(name: string) { + this.findPVSizeSelectButton().findByRole('button', { name }).should('exist'); + return this; + } + + private findPVSizeField() { + return this.find().findByTestId('create-new-storage-size'); + } + + findPVSizeMinusButton() { + return this.findPVSizeField().findByRole('button', { name: 'Minus' }); + } + + findPersistentStorageWarning() { + return this.find().findByTestId('persistent-storage-warning'); + } + + findPVSizeInput() { + return this.findPVSizeField().find('input'); + } + + findPVSizePlusButton() { + return this.findPVSizeField().findByRole('button', { name: 'Plus' }); + } +} + +class ClusterStorage { + visit(projectName: string) { + cy.visitWithLogin(`/projects/${projectName}?section=cluster-storages`); + this.wait(); + } + + private wait() { + cy.findByTestId('app-page-title'); + cy.testA11y(); + } + + findEmptyState() { + return cy.findByTestId('empty-state-title'); + } + + private findClusterStorageTable() { + return cy.findByTestId('storage-table'); + } + + findClusterStorageTableHeaderButton(name: string) { + return this.findClusterStorageTable().find('thead').findByRole('button', { name }); + } + + getClusterStorageRow(name: string) { + return new ClusterStorageRow(() => + this.findClusterStorageTable().find(`[data-label=Name]`).contains(name).parents('tr'), + ); + } + + findCreateButton() { + return cy.findByTestId('cluster-storage-button'); + } +} + +export const clusterStorage = new ClusterStorage(); +export const addClusterStorageModal = new ClusterStorageModal(); +export const updateClusterStorageModal = new ClusterStorageModal(true); diff --git a/frontend/src/components/ValueUnitField.tsx b/frontend/src/components/ValueUnitField.tsx index dbdc588028..0230df8920 100644 --- a/frontend/src/components/ValueUnitField.tsx +++ b/frontend/src/components/ValueUnitField.tsx @@ -59,6 +59,7 @@ const ValueUnitField: React.FC = ({ setOpen(!open)}> diff --git a/frontend/src/pages/projects/components/PVSizeField.tsx b/frontend/src/pages/projects/components/PVSizeField.tsx index 12172d77aa..7a80b56ff4 100644 --- a/frontend/src/pages/projects/components/PVSizeField.tsx +++ b/frontend/src/pages/projects/components/PVSizeField.tsx @@ -12,7 +12,7 @@ type PVSizeFieldProps = { }; const PVSizeField: React.FC = ({ fieldID, size, setSize, currentSize }) => ( - + setSize(value)} @@ -24,7 +24,11 @@ const PVSizeField: React.FC = ({ fieldID, size, setSize, curre {currentSize && ( - }> + } + > Storage size can only be increased. If you do so, the workbench will restart and be unavailable for a period of time that is usually proportional to the size change. diff --git a/frontend/src/pages/projects/pvc/MountPathField.tsx b/frontend/src/pages/projects/pvc/MountPathField.tsx index 9c260e61b7..fd66a9a3d7 100644 --- a/frontend/src/pages/projects/pvc/MountPathField.tsx +++ b/frontend/src/pages/projects/pvc/MountPathField.tsx @@ -28,6 +28,7 @@ const MountPathField: React.FC = ({ { imageAlt="add cluster storage" allowCreate={rbacLoaded && allowCreate} createButton={ - } diff --git a/frontend/src/pages/projects/screens/detail/storage/StorageTable.tsx b/frontend/src/pages/projects/screens/detail/storage/StorageTable.tsx index 1ffcef66ae..a3b551d0b3 100644 --- a/frontend/src/pages/projects/screens/detail/storage/StorageTable.tsx +++ b/frontend/src/pages/projects/screens/detail/storage/StorageTable.tsx @@ -22,6 +22,7 @@ const StorageTable: React.FC = ({ pvcs, refresh, onAddPVC }) data={pvcs} columns={columns} disableRowRenderSupport + data-testid="storage-table" variant="compact" rowRenderer={(pvc, i) => (