From 979b8a4ea8ff9158e714999eed0f82326b7202f8 Mon Sep 17 00:00:00 2001 From: Dipanshu Gupta Date: Fri, 12 Apr 2024 16:23:20 +0530 Subject: [PATCH] Registered model table follow-up and cypress testing --- frontend/src/__mocks__/mockModelRegistries.ts | 97 ++++++++++++++++ frontend/src/__mocks__/mockModelRegistry.ts | 58 ++++++++++ frontend/src/__mocks__/mockModelVersion.ts | 20 ++++ .../src/__mocks__/mockModelVersionList.ts | 9 ++ frontend/src/__mocks__/mockRegisteredModel.ts | 23 +++- .../src/__mocks__/mockRegisteredModelsList.ts | 108 +++++++++++++++++- .../src/__mocks__/mockRouteK8sResource.ts | 57 +++++++++ .../e2e/modelRegistry/ModelRegistry.cy.ts | 91 ++++++++++++++- .../cypress/cypress/pages/modelRegistry.ts | 72 +++++++++++- frontend/src/k8sTypes.ts | 10 +- .../modelRegistry/screens/ModelRegistry.tsx | 6 +- .../screens/RegisteredModelLabels.tsx | 31 +++-- .../screens/RegisteredModelListView.tsx | 3 + .../screens/RegisteredModelOwner.tsx | 4 +- .../screens/RegisteredModelTable.tsx | 2 +- .../screens/RegisteredModelTableRow.tsx | 4 +- 16 files changed, 570 insertions(+), 25 deletions(-) create mode 100644 frontend/src/__mocks__/mockModelRegistries.ts create mode 100644 frontend/src/__mocks__/mockModelRegistry.ts create mode 100644 frontend/src/__mocks__/mockModelVersion.ts create mode 100644 frontend/src/__mocks__/mockModelVersionList.ts diff --git a/frontend/src/__mocks__/mockModelRegistries.ts b/frontend/src/__mocks__/mockModelRegistries.ts new file mode 100644 index 0000000000..8de0ead8b8 --- /dev/null +++ b/frontend/src/__mocks__/mockModelRegistries.ts @@ -0,0 +1,97 @@ +export const mockModelRegistries = { + apiVersion: 'modelregistry.opendatahub.io/v1alpha1', + items: [ + { + apiVersion: 'modelregistry.opendatahub.io/v1alpha1', + kind: 'ModelRegistry', + metadata: { + creationTimestamp: '2024-03-19T08:16:56Z', + finalizers: ['modelregistry.opendatahub.io/finalizer'], + generation: 1, + managedFields: [], + name: 'example', + namespace: 'shared', + resourceVersion: '39722859', + uid: 'f054dd1a-53e2-4b45-bdb1-dc1a3fcf5815', + }, + spec: { + grpc: { + port: 1111, + }, + rest: { + port: 1111, + serviceRoute: 'disabled', + }, + }, + }, + { + apiVersion: 'modelregistry.opendatahub.io/v1alpha1', + kind: 'ModelRegistry', + metadata: { + annotations: { + 'kubectl.kubernetes.io/last-applied-configuration': + '{"apiVersion":"modelregistry.opendatahub.io/v1alpha1","kind":"ModelRegistry","metadata":{"annotations":{},"labels":{"app.kubernetes.io/created-by":"model-registry-operator","app.kubernetes.io/instance":"modelregistry-sample","app.kubernetes.io/managed-by":"kustomize","app.kubernetes.io/name":"modelregistry","app.kubernetes.io/part-of":"model-registry-operator"},"name":"modelregistry-sample","namespace":"shared"},"spec":{"grpc":{"port":9090},"postgres":{"database":"model-registry","host":"model-registry-db","passwordSecret":{"key":"database-password","name":"model-registry-db"},"username":"mlmduser"},"rest":{"port":8080,"serviceRoute":"disabled"}}}\n', + }, + creationTimestamp: '2024-03-14T08:01:42Z', + finalizers: ['modelregistry.opendatahub.io/finalizer'], + generation: 1, + labels: { + 'app.kubernetes.io/created-by': 'model-registry-operator', + 'app.kubernetes.io/instance': 'modelregistry-sample', + 'app.kubernetes.io/managed-by': 'kustomize', + 'app.kubernetes.io/name': 'modelregistry', + 'app.kubernetes.io/part-of': 'model-registry-operator', + }, + managedFields: [], + name: 'modelregistry-sample', + namespace: 'shared', + resourceVersion: '41871020', + uid: '6687fd4e-c417-43c1-92f4-7b6908541c83', + }, + spec: { + grpc: { + port: 9090, + }, + postgres: { + database: 'model-registry', + host: 'model-registry-db', + passwordSecret: { + key: 'database-password', + name: 'model-registry-db', + }, + port: 5432, + skipDBCreation: false, + sslMode: 'disable', + username: 'mlmduser', + }, + rest: { + port: 8080, + serviceRoute: 'disabled', + }, + }, + status: { + conditions: [ + { + lastTransitionTime: '2024-03-22T09:30:02Z', + message: 'Deployment for custom resource modelregistry-sample was successfully created', + reason: 'CreatedDeployment', + status: 'True', + type: 'Progressing', + }, + { + lastTransitionTime: '2024-03-14T08:11:26Z', + message: 'Deployment for custom resource modelregistry-sample is available', + reason: 'DeploymentAvailable', + status: 'True', + type: 'Available', + }, + ], + }, + }, + ], + kind: 'ModelRegistryList', + metadata: { + continue: '', + resourceVersion: '55673902', + }, +}; diff --git a/frontend/src/__mocks__/mockModelRegistry.ts b/frontend/src/__mocks__/mockModelRegistry.ts new file mode 100644 index 0000000000..7a582d1c96 --- /dev/null +++ b/frontend/src/__mocks__/mockModelRegistry.ts @@ -0,0 +1,58 @@ +import { ModelRegistryKind } from '~/k8sTypes'; + +type MockModelRegistryType = { + name?: string; + namespace?: string; +}; + +export const mockModelRegistry = ({ + name = 'modelregistry-sample', + namespace = 'shared', +}: MockModelRegistryType): ModelRegistryKind => ({ + apiVersion: 'modelregistry.opendatahub.io/v1alpha1', + kind: 'ModelRegistry', + metadata: { + name, + creationTimestamp: '2024-03-14T08:01:42Z', + namespace, + }, + spec: { + grpc: { + port: 9090, + }, + postgres: { + database: 'model-registry', + host: 'model-registry-db', + passwordSecret: { + key: 'database-password', + name: 'model-registry-db', + }, + port: 5432, + skipDBCreation: false, + sslMode: 'disable', + username: 'mlmduser', + }, + rest: { + port: 8080, + serviceRoute: 'disabled', + }, + }, + status: { + conditions: [ + { + lastTransitionTime: '2024-03-22T09:30:02Z', + message: 'Deployment for custom resource modelregistry-sample was successfully created', + reason: 'CreatedDeployment', + status: 'True', + type: 'Progressing', + }, + { + lastTransitionTime: '2024-03-14T08:11:26Z', + message: 'Deployment for custom resource modelregistry-sample is available', + reason: 'DeploymentAvailable', + status: 'True', + type: 'Available', + }, + ], + }, +}); diff --git a/frontend/src/__mocks__/mockModelVersion.ts b/frontend/src/__mocks__/mockModelVersion.ts new file mode 100644 index 0000000000..e1b9e5dfe6 --- /dev/null +++ b/frontend/src/__mocks__/mockModelVersion.ts @@ -0,0 +1,20 @@ +import { ModelVersion, ModelVersionState } from '~/concepts/modelRegistry/types'; + +type MockModelVersionType = { + author?: string; + registeredModelID?: string; +}; + +export const mockModelVersion = ({ + author = 'Test author', + registeredModelID = '1', +}: MockModelVersionType): ModelVersion => ({ + author, + createTimeSinceEpoch: '1712234877179', + customProperties: {}, + id: '26', + lastUpdateTimeSinceEpoch: '1712234877179', + name: 'fraud detection model version 1', + state: ModelVersionState.ARCHIVED, + registeredModelID, +}); diff --git a/frontend/src/__mocks__/mockModelVersionList.ts b/frontend/src/__mocks__/mockModelVersionList.ts new file mode 100644 index 0000000000..79f80ce406 --- /dev/null +++ b/frontend/src/__mocks__/mockModelVersionList.ts @@ -0,0 +1,9 @@ +import { ModelVersionList } from '~/concepts/modelRegistry/types'; +import { mockModelVersion } from './mockModelVersion'; + +export const mockModelVersionList = (): ModelVersionList => ({ + items: [mockModelVersion({ author: 'Author 1', registeredModelID: '1' })], + nextPageToken: '', + pageSize: 0, + size: 1, +}); diff --git a/frontend/src/__mocks__/mockRegisteredModel.ts b/frontend/src/__mocks__/mockRegisteredModel.ts index a91079973a..27cb1da678 100644 --- a/frontend/src/__mocks__/mockRegisteredModel.ts +++ b/frontend/src/__mocks__/mockRegisteredModel.ts @@ -1,17 +1,30 @@ -import { RegisteredModel, RegisteredModelState } from '~/concepts/modelRegistry/types'; +import { + ModelRegistryBase, + RegisteredModel, + RegisteredModelState, +} from '~/concepts/modelRegistry/types'; -type MockRegisteredModelType = { name?: string; state?: RegisteredModelState }; +type MockRegisteredModelType = { + id?: string; + name?: string; + state?: RegisteredModelState; + description?: string; + customProperties?: ModelRegistryBase['customProperties']; +}; export const mockRegisteredModel = ({ name = 'test', state = RegisteredModelState.LIVE, + description = '', + customProperties = {}, + id = '1', }: MockRegisteredModelType): RegisteredModel => ({ createTimeSinceEpoch: '1710404288975', - description: 'test', + description, externalID: '1234132asdfasdf', - id: '1', + id, lastUpdateTimeSinceEpoch: '1710404288975', name, state, - customProperties: {}, + customProperties, }); diff --git a/frontend/src/__mocks__/mockRegisteredModelsList.ts b/frontend/src/__mocks__/mockRegisteredModelsList.ts index cac4e1a1ed..c4b0abb6aa 100644 --- a/frontend/src/__mocks__/mockRegisteredModelsList.ts +++ b/frontend/src/__mocks__/mockRegisteredModelsList.ts @@ -1,8 +1,114 @@ +/* eslint-disable camelcase */ import { RegisteredModelList } from '~/concepts/modelRegistry/types'; import { mockRegisteredModel } from './mockRegisteredModel'; export const mockRegisteredModelList = (): RegisteredModelList => ({ - items: [mockRegisteredModel({ name: 'test-1' }), mockRegisteredModel({ name: 'test-2' })], + items: [ + mockRegisteredModel({ + name: 'Fraud detection model', + description: + 'A machine learning model trained to detect fraudulent transactions in financial data', + customProperties: { + Financial: { + metadataType: 'MetadataStringValue', + string_value: 'non-empty', + }, + 'Financial data': { + metadataType: 'MetadataStringValue', + string_value: '', + }, + 'Fraud detection': { + metadataType: 'MetadataStringValue', + string_value: '', + }, + 'Test label': { + metadataType: 'MetadataStringValue', + string_value: '', + }, + 'Machine learning': { + metadataType: 'MetadataStringValue', + string_value: '', + }, + 'Next data to be overflow': { + metadataType: 'MetadataStringValue', + string_value: '', + }, + }, + }), + mockRegisteredModel({ + name: 'Credit Scoring', + customProperties: { + 'Credit Score Predictor': { + metadataType: 'MetadataStringValue', + string_value: '', + }, + 'Creditworthiness scoring system': { + metadataType: 'MetadataStringValue', + string_value: '', + }, + 'Default Risk Analyzer': { + metadataType: 'MetadataStringValue', + string_value: '', + }, + 'Portfolio Management': { + metadataType: 'MetadataStringValue', + string_value: '', + }, + 'Risk Assessment': { + metadataType: 'MetadataStringValue', + string_value: '', + }, + }, + }), + mockRegisteredModel({ + name: 'Label modal', + description: + 'A machine learning model trained to detect fraudulent transactions in financial data', + customProperties: { + 'Testing label': { + metadataType: 'MetadataStringValue', + string_value: '', + }, + Financial: { + metadataType: 'MetadataStringValue', + string_value: 'non-empty', + }, + 'Financial data': { + metadataType: 'MetadataStringValue', + string_value: '', + }, + 'Fraud detection': { + metadataType: 'MetadataStringValue', + string_value: '', + }, + 'Long label data to be truncated abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc': + { + metadataType: 'MetadataStringValue', + string_value: '', + }, + 'Machine learning': { + metadataType: 'MetadataStringValue', + string_value: '', + }, + 'Next data to be overflow': { + metadataType: 'MetadataStringValue', + string_value: '', + }, + 'Label x': { + metadataType: 'MetadataStringValue', + string_value: '', + }, + 'Label y': { + metadataType: 'MetadataStringValue', + string_value: '', + }, + 'Label z': { + metadataType: 'MetadataStringValue', + string_value: '', + }, + }, + }), + ], nextPageToken: '', pageSize: 0, size: 4, diff --git a/frontend/src/__mocks__/mockRouteK8sResource.ts b/frontend/src/__mocks__/mockRouteK8sResource.ts index 0ed9f5e998..e80eeed7c5 100644 --- a/frontend/src/__mocks__/mockRouteK8sResource.ts +++ b/frontend/src/__mocks__/mockRouteK8sResource.ts @@ -140,3 +140,60 @@ export const mockRouteK8sResourceModelServing = ({ ], }, }); + +export const mockRouteK8sResourceModelRegistry = ({ + name = 'modelregistry-sample', + namespace = 'shared', +}: MockResourceConfigType): RouteKind => ({ + kind: 'Route', + apiVersion: 'route.openshift.io/v1', + metadata: { + name, + namespace, + uid: genUID('route'), + resourceVersion: '4789458', + creationTimestamp: '2023-02-14T21:44:13Z', + labels: { + app: name, + component: 'model-registry', + }, + annotations: { + 'openshift.io/host.generated': 'true', + }, + managedFields: [], + }, + spec: { + path: '', + host: `${name}-${namespace}.apps.user.com`, + to: { + kind: 'Service', + name, + weight: 100, + }, + port: { + targetPort: 'oauth-proxy', + }, + tls: { + termination: 'reencrypt', + insecureEdgeTerminationPolicy: 'Redirect', + }, + wildcardPolicy: 'None', + }, + status: { + ingress: [ + { + host: `${name}-${namespace}.apps.user.com`, + routerName: 'default', + conditions: [ + { + type: 'Admitted', + status: 'True', + lastTransitionTime: '2023-02-14T21:44:13Z', + }, + ], + wildcardPolicy: 'None', + routerCanonicalHostname: 'router-default.apps.user.com', + }, + ], + }, +}); diff --git a/frontend/src/__tests__/cypress/cypress/e2e/modelRegistry/ModelRegistry.cy.ts b/frontend/src/__tests__/cypress/cypress/e2e/modelRegistry/ModelRegistry.cy.ts index 74f67223f5..1b035bff1f 100644 --- a/frontend/src/__tests__/cypress/cypress/e2e/modelRegistry/ModelRegistry.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/e2e/modelRegistry/ModelRegistry.cy.ts @@ -1,6 +1,12 @@ +import { mockRouteK8sResourceModelRegistry } from '~/__mocks__'; import { mockComponents } from '~/__mocks__/mockComponents'; import { mockDashboardConfig } from '~/__mocks__/mockDashboardConfig'; +import { mockModelRegistries } from '~/__mocks__/mockModelRegistries'; +import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; +import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; +import { mockRegisteredModelList } from '~/__mocks__/mockRegisteredModelsList'; import { modelRegistry } from '~/__tests__/cypress/cypress/pages/modelRegistry'; +import { be } from '~/__tests__/cypress/cypress/utils/should'; type HandlersProps = { disableModelRegistryFeature?: boolean; @@ -14,8 +20,37 @@ const initIntercepts = ({ disableModelRegistryFeature = false }: HandlersProps) }), ); cy.interceptOdh('GET /api/components', { query: { installed: 'true' } }, mockComponents()); + + cy.intercept( + '/api/k8s/apis/modelregistry.opendatahub.io/v1alpha1/modelregistries', + mockModelRegistries, + ); + + cy.intercept( + '/api/k8s/apis/modelregistry.opendatahub.io/v1alpha1/namespaces/shared/modelregistries/modelregistry-sample', + mockModelRegistry({}), + ); + + cy.intercept( + '/api/k8s/apis/route.openshift.io/v1/namespaces/shared/routes/modelregistry-sample', + mockRouteK8sResourceModelRegistry({ name: 'modelregistry-sample', namespace: 'shared' }), + ); + + cy.intercept( + { method: 'POST', pathname: '/api/proxy/api/model_registry/v1alpha2/registered_models' }, + mockRegisteredModelList(), + ); + + cy.intercept( + { + method: 'POST', + pathname: '/api/proxy/api/model_registry/v1alpha2/registered_models/1/versions', + }, + mockModelVersionList(), + ); }; -describe('Model Registry Global', () => { + +describe('Model Registry', () => { it('Model Registry Disabled in the cluster', () => { initIntercepts({ disableModelRegistryFeature: true, @@ -35,4 +70,58 @@ describe('Model Registry Global', () => { modelRegistry.tabEnabled(); }); + + it('Model registry table', () => { + initIntercepts({ + disableModelRegistryFeature: false, + }); + + modelRegistry.visitModelRegistry(); + + const registeredModelRow = modelRegistry.getRow('Fraud detection model'); + registeredModelRow.findName().contains('Fraud detection model'); + registeredModelRow + .findDescription() + .contains( + 'A machine learning model trained to detect fraudulent transactions in financial data', + ); + registeredModelRow.findOwner().contains('Author 1'); + + // Label popover + registeredModelRow.findLabelPopoverText().contains('2 more'); + registeredModelRow.findLabelPopoverText().click(); + registeredModelRow.shouldContainsPopoverLabels([ + 'Machine learning', + 'Next data to be overflow', + ]); + + // Label modal + const registeredModelRow2 = modelRegistry.getRow('Label modal'); + registeredModelRow2.findLabelModalText().contains('6 more'); + registeredModelRow2.findLabelModalText().click(); + modelRegistry.shouldContainsModalLabels([ + 'Testing label', + 'Financial', + 'Financial data', + 'Fraud detection', + 'Machine learning', + 'Next data to be overflow', + 'Label x', + 'Label y', + 'Label z', + ]); + modelRegistry.findModalSearchInput().type('Financial'); + modelRegistry.shouldContainsModalLabels(['Financial', 'Financial data']); + modelRegistry.findCloseModal().click(); + + // sort by modelName + modelRegistry.findRegisteredModelTableHeaderButton('Model name').should(be.sortAscending); + modelRegistry.findRegisteredModelTableHeaderButton('Model name').click(); + modelRegistry.findRegisteredModelTableHeaderButton('Model name').should(be.sortDescending); + + // filtering by keyword + modelRegistry.findTableSearch().type('Fraud detection model'); + modelRegistry.findTableRows().should('have.length', 1); + modelRegistry.findTableRows().contains('Fraud detection model'); + }); }); diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts index 77d21c6cc2..d74b88db04 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts @@ -1,4 +1,33 @@ import { appChrome } from '~/__tests__/cypress/cypress/pages/appChrome'; +import { TableRow } from './components/table'; + +class RegisteredModelTableRow extends TableRow { + findName() { + return this.find().findByTestId('name'); + } + + findDescription() { + return this.find().findByTestId('description'); + } + + findOwner() { + return this.find().findByTestId('owner'); + } + + findLabelPopoverText() { + return this.find().findByTestId('popover-label-text'); + } + + findLabelModalText() { + return this.find().findByTestId('modal-label-text'); + } + + shouldContainsPopoverLabels(labels: string[]) { + this.find() + .get('[data-testid="popover-label-group"]') + .within(() => labels.map((label) => cy.contains(label))); + } +} class ModelRegistry { landingPage() { @@ -6,6 +35,11 @@ class ModelRegistry { this.waitLanding(); } + visitModelRegistry() { + cy.visitWithLogin(`/modelRegistry`); + this.wait(); + } + visit(modelRegistry?: string) { cy.visit(`/modelRegistry${modelRegistry}`); this.wait(); @@ -17,7 +51,7 @@ class ModelRegistry { } private wait() { - cy.findByTestId('app-page-title').contains('Model Registry'); + cy.findByTestId('app-page-title').should('exist'); cy.testA11y(); } @@ -39,6 +73,42 @@ class ModelRegistry { appChrome.findNavItem('Model Registry').should('not.exist'); return this; } + + findTable() { + return cy.findByTestId('registered-model-table'); + } + + findTableRows() { + return this.findTable().find('tbody tr'); + } + + getRow(name: string) { + return new RegisteredModelTableRow(() => + this.findTable().find(`[data-label="Model name"]`).contains(name).parents('tr'), + ); + } + + findRegisteredModelTableHeaderButton(name: string) { + return this.findTable().find('thead').findByRole('button', { name }); + } + + findModalSearchInput() { + return cy.findByTestId('label-modal-search'); + } + + shouldContainsModalLabels(labels: string[]) { + cy.get('[data-testid="modal-label-group"]').within(() => + labels.map((label) => cy.contains(label)), + ); + } + + findCloseModal() { + return cy.findByTestId('close-modal'); + } + + findTableSearch() { + return cy.findByTestId('registered-model-table-search'); + } } export const modelRegistry = new ModelRegistry(); diff --git a/frontend/src/k8sTypes.ts b/frontend/src/k8sTypes.ts index bf9ae1be9e..5ebea52195 100644 --- a/frontend/src/k8sTypes.ts +++ b/frontend/src/k8sTypes.ts @@ -1261,7 +1261,7 @@ export type ModelRegistryKind = K8sResourceCommon & { port: number; serviceRoute: string; }; - mysql: { + mysql?: { database: string; host: string; port?: number; @@ -1269,6 +1269,14 @@ export type ModelRegistryKind = K8sResourceCommon & { postgres: { database: string; host?: string; + passwordSecret?: { + key: string; + name: string; + }; + port: number; + skipDBCreation?: boolean; + sslMode?: string; + username?: string; }; }; status?: { diff --git a/frontend/src/pages/modelRegistry/screens/ModelRegistry.tsx b/frontend/src/pages/modelRegistry/screens/ModelRegistry.tsx index 2a8495409c..2ed82412c7 100644 --- a/frontend/src/pages/modelRegistry/screens/ModelRegistry.tsx +++ b/frontend/src/pages/modelRegistry/screens/ModelRegistry.tsx @@ -1,7 +1,5 @@ import React from 'react'; import ApplicationsPage from '~/pages/ApplicationsPage'; -import { ProjectObjectType } from '~/concepts/design/utils'; -import TitleWithIcon from '~/concepts/design/TitleWithIcon'; import ModelRegistryEmpty from '~/pages/modelRegistry/ModelRegistryEmpty'; import { ModelRegistryContext } from '~/concepts/modelRegistry/context/ModelRegistryContext'; import useRegisteredModels from '~/concepts/modelRegistry/apiHooks/useRegisteredModels'; @@ -15,9 +13,7 @@ const ModelRegistry: React.FC = () => { } - title={ - - } + title="Registered models" description="View and manage your registered models." loadError={loadError} loaded={loaded} diff --git a/frontend/src/pages/modelRegistry/screens/RegisteredModelLabels.tsx b/frontend/src/pages/modelRegistry/screens/RegisteredModelLabels.tsx index 94627a697b..c185ff4f4d 100644 --- a/frontend/src/pages/modelRegistry/screens/RegisteredModelLabels.tsx +++ b/frontend/src/pages/modelRegistry/screens/RegisteredModelLabels.tsx @@ -40,7 +40,7 @@ const RegisteredModelLabels: React.FC = ({ const labelsComponent = (labels: string[], textMaxWidth?: string) => labels.map((label, index) => ( -